From 05b728fffcdb17708fdb52685725faf7fdc545bc Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sun, 12 Nov 2023 13:31:11 -0600 Subject: [PATCH] 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 --- src/lib/labeling/const.ts | 89 ---- src/lib/labeling/types.ts | 18 - src/state/modals/index.tsx | 2 +- src/state/models/root-store.ts | 2 - src/state/models/ui/create-account.ts | 1 - src/state/models/ui/preferences.ts | 420 +----------------- src/state/models/ui/saved-feeds.ts | 33 -- src/state/queries/feed.ts | 106 +++++ src/state/queries/preferences/const.ts | 27 ++ src/state/queries/preferences/index.ts | 257 +++++++++++ src/state/queries/preferences/moderation.ts | 163 +++++++ src/state/queries/preferences/types.ts | 46 ++ src/state/queries/preferences/util.ts | 16 + src/view/com/auth/create/CreateAccount.tsx | 23 +- src/view/com/feeds/FeedSourceCard.tsx | 145 ++++++ src/view/com/modals/BirthDateSettings.tsx | 52 ++- .../com/modals/ContentFilteringSettings.tsx | 220 ++++----- src/view/com/testing/TestCtrls.e2e.tsx | 6 +- src/view/screens/PreferencesHomeFeed.tsx | 143 ++++-- src/view/screens/PreferencesThreads.tsx | 154 ++++--- src/view/screens/SavedFeeds.tsx | 317 +++++++------ src/view/screens/Settings.tsx | 9 +- 22 files changed, 1337 insertions(+), 912 deletions(-) delete mode 100644 src/lib/labeling/const.ts delete mode 100644 src/lib/labeling/types.ts create mode 100644 src/state/queries/feed.ts create mode 100644 src/state/queries/preferences/const.ts create mode 100644 src/state/queries/preferences/index.ts create mode 100644 src/state/queries/preferences/moderation.ts create mode 100644 src/state/queries/preferences/types.ts create mode 100644 src/state/queries/preferences/util.ts diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts deleted file mode 100644 index 5c2e68137d..0000000000 --- a/src/lib/labeling/const.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {LabelPreferencesModel} from 'state/models/ui/preferences' -import {LabelValGroup} from './types' - -export const ILLEGAL_LABEL_GROUP: LabelValGroup = { - id: 'illegal', - title: 'Illegal Content', - warning: 'Illegal Content', - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], -} - -export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = { - id: 'always-filter', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!filter'], -} - -export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = { - id: 'always-warn', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!warn', 'account-security'], -} - -export const UNKNOWN_LABEL_GROUP: LabelValGroup = { - id: 'unknown', - title: 'Unknown Label', - warning: 'Content Warning', - values: [], -} - -export const CONFIGURABLE_LABEL_GROUPS: Record< - keyof LabelPreferencesModel, - LabelValGroup -> = { - 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'], - }, -} diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts deleted file mode 100644 index 84d59be7fd..0000000000 --- a/src/lib/labeling/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' -import {LabelPreferencesModel} from 'state/models/ui/preferences' - -export type Label = ComAtprotoLabelDefs.Label - -export interface LabelValGroup { - id: - | keyof LabelPreferencesModel - | 'illegal' - | 'always-filter' - | 'always-warn' - | 'unknown' - title: string - isAdultImagery?: boolean - subtitle?: string - warning: string - values: string[] -} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index f9bd1e3c9a..287bbe593e 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -243,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const closeModal = React.useCallback(() => { let totalActiveModals = 0 setActiveModals(activeModals => { - activeModals.pop() + activeModals = activeModals.slice(0, -1) totalActiveModals = activeModals.length return activeModals }) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 4085a52c31..c07cf30781 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -101,7 +101,6 @@ export class RootStoreModel { this.agent = agent applyDebugHeader(this.agent) this.me.clear() - await this.preferences.sync() await this.me.load() if (!hadSession) { await resetNavigation() @@ -137,7 +136,6 @@ export class RootStoreModel { } try { await this.me.updateIfNeeded() - await this.preferences.sync() } catch (e: any) { logger.error('Failed to fetch latest state', {error: e}) } diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 6d76784c19..60f4fc1844 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -127,7 +127,6 @@ export class CreateAccountModel { password: this.password, inviteCode: this.inviteCode.trim(), }) - /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate) track('Create Account') } catch (e: any) { onboardingDispatch({type: 'skip'}) // undo starting the onboard diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 9514865920..4f43487e7c 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,19 +1,13 @@ -import {makeAutoObservable, runInAction} from 'mobx' +import {makeAutoObservable} from 'mobx' import { LabelPreference as APILabelPreference, BskyFeedViewPreference, BskyThreadViewPreference, } from '@atproto/api' import AwaitLock from 'await-lock' -import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {ModerationOpts} from '@atproto/api' -import {DEFAULT_FEEDS} from 'lib/constants' -import {getAge} from 'lib/strings/time' -import {FeedTuner} from 'lib/api/feed-manip' -import {logger} from '#/logger' -import {getContentLanguages} from '#/state/preferences/languages' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf export type LabelPreference = APILabelPreference | 'show' @@ -23,24 +17,6 @@ export type FeedViewPreference = BskyFeedViewPreference & { export type ThreadViewPreference = BskyThreadViewPreference & { lab_treeViewEnabled?: boolean | undefined } -const LABEL_GROUPS = [ - 'nsfw', - 'nudity', - 'suggestive', - 'gore', - 'hate', - 'spam', - 'impersonation', -] -const VISIBILITY_VALUES = ['ignore', 'warn', 'hide'] -const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] - -interface LegacyPreferences { - hideReplies?: boolean - hideRepliesByLikeCount?: number - hideReposts?: boolean - hideQuotePosts?: boolean -} export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -76,9 +52,6 @@ export class PreferencesModel { lab_treeViewEnabled: false, // experimental } - // used to help with transitions from device-stored to server-stored preferences - legacyPreferences: LegacyPreferences | undefined - // used to linearize async modifications to state lock = new AwaitLock() @@ -86,13 +59,6 @@ export class PreferencesModel { makeAutoObservable(this, {lock: false}, {autoBind: true}) } - get userAge(): number | undefined { - if (!this.birthDate) { - return undefined - } - return getAge(this.birthDate) - } - serialize() { return { contentLabels: this.contentLabels, @@ -128,117 +94,15 @@ export class PreferencesModel { ) { this.pinnedFeeds = v.pinnedFeeds } - // grab legacy values - this.legacyPreferences = getLegacyPreferences(v) - } - } - - /** - * This function fetches preferences and sets defaults for missing items. - */ - async sync() { - await this.lock.acquireAsync() - try { - // fetch preferences - const prefs = await this.rootStore.agent.getPreferences() - - runInAction(() => { - if (prefs.feedViewPrefs.home) { - this.homeFeed = prefs.feedViewPrefs.home - } - this.thread = prefs.threadViewPrefs - this.adultContentEnabled = prefs.adultContentEnabled - for (const label in prefs.contentLabels) { - if ( - LABEL_GROUPS.includes(label) && - VISIBILITY_VALUES.includes(prefs.contentLabels[label]) - ) { - this.contentLabels[label as keyof LabelPreferencesModel] = - prefs.contentLabels[label] - } - } - if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) { - this.savedFeeds = prefs.feeds.saved - } - if ( - prefs.feeds.pinned && - !isEqual(this.pinnedFeeds, prefs.feeds.pinned) - ) { - this.pinnedFeeds = prefs.feeds.pinned - } - this.birthDate = prefs.birthDate - }) - - // sync legacy values if needed - await this.syncLegacyPreferences() - - // set defaults on missing items - if (typeof prefs.feeds.saved === 'undefined') { - try { - const {saved, pinned} = await DEFAULT_FEEDS( - this.rootStore.agent.service.toString(), - (handle: string) => - this.rootStore.agent - .resolveHandle({handle}) - .then(({data}) => data.did), - ) - runInAction(() => { - this.savedFeeds = saved - this.pinnedFeeds = pinned - }) - await this.rootStore.agent.setSavedFeeds(saved, pinned) - } catch (error) { - logger.error('Failed to set default feeds', {error}) - } - } - } finally { - this.lock.release() - } - } - - async syncLegacyPreferences() { - if (this.legacyPreferences) { - this.homeFeed = {...this.homeFeed, ...this.legacyPreferences} - this.legacyPreferences = undefined - await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed) - } - } - - /** - * This function resets the preferences to an empty array of no preferences. - */ - async reset() { - await this.lock.acquireAsync() - try { - runInAction(() => { - this.contentLabels = new LabelPreferencesModel() - this.savedFeeds = [] - this.pinnedFeeds = [] - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: [], - }) - } finally { - this.lock.release() } } // moderation // = - async setContentLabelPref( - key: keyof LabelPreferencesModel, - value: LabelPreference, - ) { - this.contentLabels[key] = value - await this.rootStore.agent.setContentLabelPref(key, value) - } - - async setAdultContentEnabled(v: boolean) { - this.adultContentEnabled = v - await this.rootStore.agent.setAdultContentEnabled(v) - } - + /** + * @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead + */ get moderationOpts(): ModerationOpts { return { userDid: this.rootStore.session.currentSession?.did || '', @@ -284,274 +148,32 @@ export class PreferencesModel { return this.pinnedFeeds.includes(uri) } - async _optimisticUpdateSavedFeeds( - saved: string[], - pinned: string[], - cb: () => Promise<{saved: string[]; pinned: string[]}>, - ) { - const oldSaved = this.savedFeeds - const oldPinned = this.pinnedFeeds - this.savedFeeds = saved - this.pinnedFeeds = pinned - await this.lock.acquireAsync() - try { - const res = await cb() - runInAction(() => { - this.savedFeeds = res.saved - this.pinnedFeeds = res.pinned - }) - } catch (e) { - runInAction(() => { - this.savedFeeds = oldSaved - this.pinnedFeeds = oldPinned - }) - throw e - } finally { - this.lock.release() - } - } - - async setSavedFeeds(saved: string[], pinned: string[]) { - return this._optimisticUpdateSavedFeeds(saved, pinned, () => - this.rootStore.agent.setSavedFeeds(saved, pinned), - ) - } - - async addSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - this.pinnedFeeds, - () => this.rootStore.agent.addSavedFeed(v), - ) - } - - async removeSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds.filter(uri => uri !== v), - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removeSavedFeed(v), - ) - } - - async addPinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - [...this.pinnedFeeds.filter(uri => uri !== v), v], - () => this.rootStore.agent.addPinnedFeed(v), - ) - } - - async removePinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removePinnedFeed(v), - ) - } - - // other - // = - - async setBirthDate(birthDate: Date) { - this.birthDate = birthDate - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setPersonalDetails({birthDate}) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReplies() { - this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideRepliesByUnfollowed() { - this.homeFeed.hideRepliesByUnfollowed = - !this.homeFeed.hideRepliesByUnfollowed - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) - } finally { - this.lock.release() - } - } - - async setHomeFeedHideRepliesByLikeCount(threshold: number) { - this.homeFeed.hideRepliesByLikeCount = threshold - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReposts() { - this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideQuotePosts() { - this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedMergeFeedEnabled() { - this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) - } finally { - this.lock.release() - } - } - - async setThreadSort(v: string) { - if (THREAD_SORT_VALUES.includes(v)) { - this.thread.sort = v - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({sort: v}) - } finally { - this.lock.release() - } - } - } - - async togglePrioritizedFollowedUsers() { - this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) - } finally { - this.lock.release() - } - } - - async toggleThreadTreeViewEnabled() { - this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) - } finally { - this.lock.release() - } - } - - getFeedTuners( - feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', - ) { - if (feedType === 'custom') { - return [ - FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly(getContentLanguages()), - ] - } - if (feedType === 'list') { - return [FeedTuner.dedupReposts] - } - if (feedType === 'home' || feedType === 'following') { - const feedTuners = [] - - if (this.homeFeed.hideReposts) { - feedTuners.push(FeedTuner.removeReposts) - } else { - feedTuners.push(FeedTuner.dedupReposts) - } + /** + * @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead + */ + async addSavedFeed(_v: string) {} - if (this.homeFeed.hideReplies) { - feedTuners.push(FeedTuner.removeReplies) - } else { - feedTuners.push( - FeedTuner.thresholdRepliesOnly({ - userDid: this.rootStore.session.data?.did || '', - minLikes: this.homeFeed.hideRepliesByLikeCount, - followedOnly: !!this.homeFeed.hideRepliesByUnfollowed, - }), - ) - } + /** + * @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead + */ + async removeSavedFeed(_v: string) {} - if (this.homeFeed.hideQuotePosts) { - feedTuners.push(FeedTuner.removeQuotePosts) - } + /** + * @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead + */ + async addPinnedFeed(_v: string) {} - return feedTuners - } - return [] - } + /** + * @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead + */ + async removePinnedFeed(_v: string) {} } // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +// TODO do we need this? function tempfixLabelPref(pref: LabelPreference): APILabelPreference { if (pref === 'show') { return 'ignore' } return pref } - -function getLegacyPreferences( - v: Record, -): LegacyPreferences | undefined { - const legacyPreferences: LegacyPreferences = {} - if ( - hasProp(v, 'homeFeedRepliesEnabled') && - typeof v.homeFeedRepliesEnabled === 'boolean' - ) { - legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled - } - if ( - hasProp(v, 'homeFeedRepliesThreshold') && - typeof v.homeFeedRepliesThreshold === 'number' - ) { - legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold - } - if ( - hasProp(v, 'homeFeedRepostsEnabled') && - typeof v.homeFeedRepostsEnabled === 'boolean' - ) { - legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled - } - if ( - hasProp(v, 'homeFeedQuotePostsEnabled') && - typeof v.homeFeedQuotePostsEnabled === 'boolean' - ) { - legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled - } - if (Object.keys(legacyPreferences).length) { - return legacyPreferences - } - return undefined -} diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 624da4f5f2..cf4cf6d712 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -3,7 +3,6 @@ import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' import {FeedSourceModel} from '../content/feed-source' -import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' export class SavedFeedsModel { @@ -69,7 +68,6 @@ export class SavedFeedsModel { refresh = bundleAsync(async () => { this._xLoading(true) try { - await this.rootStore.preferences.sync() const uris = dedup( this.rootStore.preferences.pinnedFeeds.concat( this.rootStore.preferences.savedFeeds, @@ -87,37 +85,6 @@ export class SavedFeedsModel { } }) - async reorderPinnedFeeds(feeds: FeedSourceModel[]) { - this._updatePinSortOrder(feeds.map(f => f.uri)) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - feeds.filter(feed => feed.isPinned).map(feed => feed.uri), - ) - } - - async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { - const pinned = this.rootStore.preferences.pinnedFeeds.slice() - const index = pinned.indexOf(item.uri) - if (index === -1) { - return - } - if (direction === 'up' && index !== 0) { - ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] - } else if (direction === 'down' && index < pinned.length - 1) { - ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] - } - this._updatePinSortOrder(pinned.concat(this.unpinned.map(f => f.uri))) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - pinned, - ) - track('CustomFeed:Reorder', { - name: item.displayName, - uri: item.uri, - index: pinned.indexOf(item.uri), - }) - } - // state transitions // = diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts new file mode 100644 index 0000000000..0ba323314b --- /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 0000000000..5db137e585 --- /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 0000000000..d64bbd9542 --- /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({ + 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({ + 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>({ + 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>({ + 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 + >({ + 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({ + 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({ + 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({ + 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({ + 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 0000000000..a26380a36a --- /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 0000000000..9f4c30e536 --- /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 + feedViewPrefs: BskyPreferences['feedViewPrefs']['home'] + /** + * User thread-view prefs, including newer fields that may not be typed yet. + */ + threadViewPrefs: ThreadViewPreferences + userAge: number | undefined + feeds: Required & { + unpinned: string[] + } +} + +export type ThreadViewPreferences = Omit & { + 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 0000000000..7b8160c283 --- /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 +} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 65f9ba26dd..0f3ff41af8 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -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 ( + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean +}) { + const pal = usePalette('default') + const navigation = useNavigation() + 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 ( + { + 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}> + + + + + + + {info.displayName} + + + {info.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(info.creatorHandle, '@')} + + + + {showSaveBtn && info.type === 'feed' && ( + + + {isSaved ? ( + + ) : ( + + )} + + + )} + + + {showDescription && info.description ? ( + + ) : null} + + {showLikes && info.type === 'feed' ? ( + + Liked by {info.likeCount || 0}{' '} + {pluralize(info.likeCount || 0, 'user')} + + ) : null} + + ) +}) export const FeedSourceCard = observer(function FeedSourceCardImpl({ item, diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx index 6655b7a6b1..9996c56410 100644 --- a/src/view/com/modals/BirthDateSettings.tsx +++ b/src/view/com/modals/BirthDateSettings.tsx @@ -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 {isMobile} = useWebMediaQueries() const {_} = useLingui() + const { + isPending, + isError, + error, + mutateAsync: setBirthDate, + } = usePreferencesSetBirthDateMutation() + const [date, setDate] = useState(preferences.birthDate || new Date()) const {closeModal} = useModalControls() - const [date, setDate] = useState( - store.preferences.birthDate || new Date(), - ) - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState('') - const {isMobile} = useWebMediaQueries() - 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 ( - {error ? ( - + {isError ? ( + ) : undefined} - {isProcessing ? ( + {isPending ? ( @@ -99,6 +101,16 @@ export const Component = observer(function Component({}: {}) { ) +} + +export const Component = observer(function Component({}: {}) { + const {data: preferences} = usePreferencesQuery() + + return !preferences ? ( + + ) : ( + + ) }) const styles = StyleSheet.create({ diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index ad4a0fa521..cd539406cf 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -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( Content Filtering + + + + - - - + 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 ( - - {isIOS ? ( - store.preferences.adultContentEnabled ? null : ( - - Adult content can only be enabled via the Web at{' '} - - . - - ) - ) : typeof store.preferences.birthDate === 'undefined' ? ( - - - Confirm your age to enable adult content. - -