Eric/preferences (#1873)

* Add initial preferences query, couple mutations

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Eric Bailey 2023-11-12 13:31:11 -06:00 committed by GitHub
parent c8c308e31e
commit 05b728fffc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1339 additions and 914 deletions

View File

@ -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'],
},
}

View File

@ -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[]
}

View File

@ -243,7 +243,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const closeModal = React.useCallback(() => { const closeModal = React.useCallback(() => {
let totalActiveModals = 0 let totalActiveModals = 0
setActiveModals(activeModals => { setActiveModals(activeModals => {
activeModals.pop() activeModals = activeModals.slice(0, -1)
totalActiveModals = activeModals.length totalActiveModals = activeModals.length
return activeModals return activeModals
}) })

View File

@ -101,7 +101,6 @@ export class RootStoreModel {
this.agent = agent this.agent = agent
applyDebugHeader(this.agent) applyDebugHeader(this.agent)
this.me.clear() this.me.clear()
await this.preferences.sync()
await this.me.load() await this.me.load()
if (!hadSession) { if (!hadSession) {
await resetNavigation() await resetNavigation()
@ -137,7 +136,6 @@ export class RootStoreModel {
} }
try { try {
await this.me.updateIfNeeded() await this.me.updateIfNeeded()
await this.preferences.sync()
} catch (e: any) { } catch (e: any) {
logger.error('Failed to fetch latest state', {error: e}) logger.error('Failed to fetch latest state', {error: e})
} }

View File

@ -127,7 +127,6 @@ export class CreateAccountModel {
password: this.password, password: this.password,
inviteCode: this.inviteCode.trim(), inviteCode: this.inviteCode.trim(),
}) })
/* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
track('Create Account') track('Create Account')
} catch (e: any) { } catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard onboardingDispatch({type: 'skip'}) // undo starting the onboard

View File

@ -1,19 +1,13 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable} from 'mobx'
import { import {
LabelPreference as APILabelPreference, LabelPreference as APILabelPreference,
BskyFeedViewPreference, BskyFeedViewPreference,
BskyThreadViewPreference, BskyThreadViewPreference,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import isEqual from 'lodash.isequal'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {ModerationOpts} from '@atproto/api' 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 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
export type LabelPreference = APILabelPreference | 'show' export type LabelPreference = APILabelPreference | 'show'
@ -23,24 +17,6 @@ export type FeedViewPreference = BskyFeedViewPreference & {
export type ThreadViewPreference = BskyThreadViewPreference & { export type ThreadViewPreference = BskyThreadViewPreference & {
lab_treeViewEnabled?: boolean | undefined 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 { export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide' nsfw: LabelPreference = 'hide'
@ -76,9 +52,6 @@ export class PreferencesModel {
lab_treeViewEnabled: false, // experimental 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 // used to linearize async modifications to state
lock = new AwaitLock() lock = new AwaitLock()
@ -86,13 +59,6 @@ export class PreferencesModel {
makeAutoObservable(this, {lock: false}, {autoBind: true}) makeAutoObservable(this, {lock: false}, {autoBind: true})
} }
get userAge(): number | undefined {
if (!this.birthDate) {
return undefined
}
return getAge(this.birthDate)
}
serialize() { serialize() {
return { return {
contentLabels: this.contentLabels, contentLabels: this.contentLabels,
@ -128,117 +94,15 @@ export class PreferencesModel {
) { ) {
this.pinnedFeeds = v.pinnedFeeds 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 // moderation
// = // =
async setContentLabelPref( /**
key: keyof LabelPreferencesModel, * @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead
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)
}
get moderationOpts(): ModerationOpts { get moderationOpts(): ModerationOpts {
return { return {
userDid: this.rootStore.session.currentSession?.did || '', userDid: this.rootStore.session.currentSession?.did || '',
@ -284,274 +148,32 @@ export class PreferencesModel {
return this.pinnedFeeds.includes(uri) return this.pinnedFeeds.includes(uri)
} }
async _optimisticUpdateSavedFeeds( /**
saved: string[], * @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
pinned: string[], */
cb: () => Promise<{saved: string[]; pinned: string[]}>, async addSavedFeed(_v: 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, () => * @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
this.rootStore.agent.setSavedFeeds(saved, pinned), */
) async removeSavedFeed(_v: string) {}
}
async addSavedFeed(v: string) { /**
return this._optimisticUpdateSavedFeeds( * @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
[...this.savedFeeds.filter(uri => uri !== v), v], */
this.pinnedFeeds, async addPinnedFeed(_v: string) {}
() => this.rootStore.agent.addSavedFeed(v),
)
}
async removeSavedFeed(v: string) { /**
return this._optimisticUpdateSavedFeeds( * @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
this.savedFeeds.filter(uri => uri !== v), */
this.pinnedFeeds.filter(uri => uri !== v), async removePinnedFeed(_v: string) {}
() => 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)
}
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,
}),
)
}
if (this.homeFeed.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}
} }
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf // 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 { function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
if (pref === 'show') { if (pref === 'show') {
return 'ignore' return 'ignore'
} }
return pref return pref
} }
function getLegacyPreferences(
v: Record<string, unknown>,
): 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
}

View File

@ -3,7 +3,6 @@ import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {FeedSourceModel} from '../content/feed-source' import {FeedSourceModel} from '../content/feed-source'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger' import {logger} from '#/logger'
export class SavedFeedsModel { export class SavedFeedsModel {
@ -69,7 +68,6 @@ export class SavedFeedsModel {
refresh = bundleAsync(async () => { refresh = bundleAsync(async () => {
this._xLoading(true) this._xLoading(true)
try { try {
await this.rootStore.preferences.sync()
const uris = dedup( const uris = dedup(
this.rootStore.preferences.pinnedFeeds.concat( this.rootStore.preferences.pinnedFeeds.concat(
this.rootStore.preferences.savedFeeds, 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 // state transitions
// = // =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {useSessionApi} from '#/state/session' 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 {Step1} from './Step1'
import {Step2} from './Step2' import {Step2} from './Step2'
@ -36,6 +42,8 @@ export const CreateAccount = observer(function CreateAccountImpl({
const {_} = useLingui() const {_} = useLingui()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi() const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
React.useEffect(() => { React.useEffect(() => {
screen('CreateAccount') screen('CreateAccount')
@ -70,13 +78,26 @@ export const CreateAccount = observer(function CreateAccountImpl({
onboardingDispatch, onboardingDispatch,
createAccount, createAccount,
}) })
setBirthDate({birthDate: model.birthDate})
if (IS_PROD(model.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
}
} catch { } catch {
// dont need to handle here // dont need to handle here
} finally { } finally {
track('Try Create Account') track('Try Create Account')
} }
} }
}, [model, track, onboardingDispatch, createAccount]) }, [
model,
track,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
])
return ( return (
<LoggedOutLayout <LoggedOutLayout

View File

@ -16,6 +16,151 @@ import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
useSaveFeedMutation,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
export const NewFeedSourceCard = observer(function FeedSourceCardImpl({
feedUri,
style,
showSaveBtn = false,
showDescription = false,
showLikes = false,
}: {
feedUri: string
style?: StyleProp<ViewStyle>
showSaveBtn?: boolean
showDescription?: boolean
showLikes?: boolean
}) {
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
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 (
<Pressable
testID={`feed-${info.displayName}`}
accessibilityRole="button"
style={[styles.container, pal.border, style]}
onPress={() => {
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}>
<View style={[styles.headerContainer]}>
<View style={[s.mr10]}>
<UserAvatar type="algo" size={36} avatar={info.avatar} />
</View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{info.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{info.type === 'feed' ? 'Feed' : 'List'} by{' '}
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
</View>
{showSaveBtn && info.type === 'feed' && (
<View>
<Pressable
disabled={isSavePending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? 'Remove from my feeds' : 'Add to my feeds'
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && info.description ? (
<RichText
style={[pal.textLight, styles.description]}
richText={info.description}
numberOfLines={3}
/>
) : null}
{showLikes && info.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {info.likeCount || 0}{' '}
{pluralize(info.likeCount || 0, 'user')}
</Text>
) : null}
</Pressable>
)
})
export const FeedSourceCard = observer(function FeedSourceCardImpl({ export const FeedSourceCard = observer(function FeedSourceCardImpl({
item, item,

View File

@ -9,7 +9,6 @@ import {observer} from 'mobx-react-lite'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {DateInput} from '../util/forms/DateInput' import {DateInput} from '../util/forms/DateInput'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
@ -18,33 +17,36 @@ import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
usePreferencesSetBirthDateMutation,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
export const snapPoints = ['50%'] export const snapPoints = ['50%']
export const Component = observer(function Component({}: {}) { function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {_} = useLingui()
const {closeModal} = useModalControls()
const [date, setDate] = useState<Date>(
store.preferences.birthDate || new Date(),
)
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const {
isPending,
isError,
error,
mutateAsync: setBirthDate,
} = usePreferencesSetBirthDateMutation()
const [date, setDate] = useState(preferences.birthDate || new Date())
const {closeModal} = useModalControls()
const onSave = async () => { const onSave = React.useCallback(async () => {
setError('')
setIsProcessing(true)
try { try {
await store.preferences.setBirthDate(date) await setBirthDate({birthDate: date})
closeModal() closeModal()
} catch (e) { } catch (e) {
setError(cleanError(String(e))) logger.error(`setBirthDate failed`, {error: e})
} finally {
setIsProcessing(false)
} }
} }, [date, setBirthDate, closeModal])
return ( return (
<View <View
@ -74,12 +76,12 @@ export const Component = observer(function Component({}: {}) {
/> />
</View> </View>
{error ? ( {isError ? (
<ErrorMessage message={error} style={styles.error} /> <ErrorMessage message={cleanError(error)} style={styles.error} />
) : undefined} ) : undefined}
<View style={[styles.btnContainer, pal.borderDark]}> <View style={[styles.btnContainer, pal.borderDark]}>
{isProcessing ? ( {isPending ? (
<View style={styles.btn}> <View style={styles.btn}>
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
</View> </View>
@ -99,6 +101,16 @@ export const Component = observer(function Component({}: {}) {
</View> </View>
</View> </View>
) )
}
export const Component = observer(function Component({}: {}) {
const {data: preferences} = usePreferencesQuery()
return !preferences ? (
<ActivityIndicator />
) : (
<Inner preferences={preferences} />
)
}) })
const styles = StyleSheet.create({ const styles = StyleSheet.create({

View File

@ -1,17 +1,15 @@
import React from 'react' import React from 'react'
import {BskyPreferences, LabelPreference} from '@atproto/api'
import {StyleSheet, Pressable, View} from 'react-native' import {StyleSheet, Pressable, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ScrollView} from './util' import {ScrollView} from './util'
import {useStores} from 'state/index'
import {LabelPreference} from 'state/models/ui/preferences'
import {s, colors, gradients} from 'lib/styles' import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link' import {TextLink} from '../util/Link'
import {ToggleButton} from '../util/forms/ToggleButton' import {ToggleButton} from '../util/forms/ToggleButton'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
@ -19,20 +17,23 @@ import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
usePreferencesSetAdultContentMutation,
ConfigurableLabelGroup,
CONFIGURABLE_LABEL_GROUPS,
} from '#/state/queries/preferences'
export const snapPoints = ['90%'] export const snapPoints = ['90%']
export const Component = observer( export const Component = observer(
function ContentFilteringSettingsImpl({}: {}) { function ContentFilteringSettingsImpl({}: {}) {
const store = useStores()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const {data: preferences} = usePreferencesQuery()
React.useEffect(() => {
store.preferences.sync()
}, [store])
const onPressDone = React.useCallback(() => { const onPressDone = React.useCallback(() => {
closeModal() closeModal()
@ -43,29 +44,38 @@ export const Component = observer(
<Text style={[pal.text, styles.title]}> <Text style={[pal.text, styles.title]}>
<Trans>Content Filtering</Trans> <Trans>Content Filtering</Trans>
</Text> </Text>
<ScrollView style={styles.scrollContainer}> <ScrollView style={styles.scrollContainer}>
<AdultContentEnabledPref /> <AdultContentEnabledPref />
<ContentLabelPref <ContentLabelPref
group="nsfw" preferences={preferences}
disabled={!store.preferences.adultContentEnabled} labelGroup="nsfw"
disabled={!preferences?.adultContentEnabled}
/> />
<ContentLabelPref <ContentLabelPref
group="nudity" preferences={preferences}
disabled={!store.preferences.adultContentEnabled} labelGroup="nudity"
disabled={!preferences?.adultContentEnabled}
/> />
<ContentLabelPref <ContentLabelPref
group="suggestive" preferences={preferences}
disabled={!store.preferences.adultContentEnabled} labelGroup="suggestive"
disabled={!preferences?.adultContentEnabled}
/> />
<ContentLabelPref <ContentLabelPref
group="gore" preferences={preferences}
disabled={!store.preferences.adultContentEnabled} labelGroup="gore"
disabled={!preferences?.adultContentEnabled}
/>
<ContentLabelPref preferences={preferences} labelGroup="hate" />
<ContentLabelPref preferences={preferences} labelGroup="spam" />
<ContentLabelPref
preferences={preferences}
labelGroup="impersonation"
/> />
<ContentLabelPref group="hate" />
<ContentLabelPref group="spam" />
<ContentLabelPref group="impersonation" />
<View style={{height: isMobile ? 60 : 0}} /> <View style={{height: isMobile ? 60 : 0}} />
</ScrollView> </ScrollView>
<View <View
style={[ style={[
styles.btnContainer, styles.btnContainer,
@ -94,118 +104,114 @@ export const Component = observer(
}, },
) )
const AdultContentEnabledPref = observer( function AdultContentEnabledPref() {
function AdultContentEnabledPrefImpl() { const pal = usePalette('default')
const store = useStores() const {data: preferences} = usePreferencesQuery()
const pal = usePalette('default') const {mutate, variables} = usePreferencesSetAdultContentMutation()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onSetAge = () => openModal({name: 'birth-date-settings'}) const onSetAge = React.useCallback(
() => openModal({name: 'birth-date-settings'}),
[openModal],
)
const onToggleAdultContent = async () => { const onToggleAdultContent = React.useCallback(async () => {
if (isIOS) { if (isIOS) return
return
} try {
try { mutate({
await store.preferences.setAdultContentEnabled( enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
!store.preferences.adultContentEnabled, })
) } catch (e) {
} catch (e) { Toast.show('There was an issue syncing your preferences with the server')
Toast.show( logger.error('Failed to update preferences with server', {error: e})
'There was an issue syncing your preferences with the server',
)
logger.error('Failed to update preferences with server', {error: e})
}
} }
}, [variables, preferences, mutate])
return ( return (
<View style={s.mb10}> <View style={s.mb10}>
{isIOS ? ( {isIOS ? (
store.preferences.adultContentEnabled ? null : ( preferences?.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '} Adult content can only be enabled via the Web at{' '}
<TextLink <TextLink
style={pal.link} style={pal.link}
href="https://bsky.app" href="https://bsky.app"
text="bsky.app" text="bsky.app"
/> />
. .
</Text> </Text>
) )
) : typeof store.preferences.birthDate === 'undefined' ? ( ) : typeof preferences?.birthDate === 'undefined' ? (
<View style={[pal.viewLight, styles.agePrompt]}> <View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}> <Text type="md" style={[pal.text, {flex: 1}]}>
Confirm your age to enable adult content. Confirm your age to enable adult content.
</Text> </Text>
<Button type="primary" label="Set Age" onPress={onSetAge} /> <Button type="primary" label="Set Age" onPress={onSetAge} />
</View> </View>
) : (store.preferences.userAge || 0) >= 18 ? ( ) : (preferences.userAge || 0) >= 18 ? (
<ToggleButton <ToggleButton
type="default-light" type="default-light"
label="Enable Adult Content" label="Enable Adult Content"
isSelected={store.preferences.adultContentEnabled} isSelected={variables?.enabled ?? preferences?.adultContentEnabled}
onPress={onToggleAdultContent} onPress={onToggleAdultContent}
style={styles.toggleBtn} style={styles.toggleBtn}
/> />
) : ( ) : (
<View style={[pal.viewLight, styles.agePrompt]}> <View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}> <Text type="md" style={[pal.text, {flex: 1}]}>
You must be 18 or older to enable adult content. You must be 18 or older to enable adult content.
</Text> </Text>
<Button type="primary" label="Set Age" onPress={onSetAge} /> <Button type="primary" label="Set Age" onPress={onSetAge} />
</View> </View>
)} )}
</View> </View>
) )
}, }
)
// TODO: Refactor this component to pass labels down to each tab // TODO: Refactor this component to pass labels down to each tab
const ContentLabelPref = observer(function ContentLabelPrefImpl({ const ContentLabelPref = observer(function ContentLabelPrefImpl({
group, preferences,
labelGroup,
disabled, disabled,
}: { }: {
group: keyof typeof CONFIGURABLE_LABEL_GROUPS preferences?: BskyPreferences
labelGroup: ConfigurableLabelGroup
disabled?: boolean disabled?: boolean
}) { }) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const visibility = preferences?.contentLabels?.[labelGroup]
const {mutate, variables} = usePreferencesSetContentLabelMutation()
const onChange = React.useCallback( const onChange = React.useCallback(
async (v: LabelPreference) => { (vis: LabelPreference) => {
try { mutate({labelGroup, visibility: vis})
await store.preferences.setContentLabelPref(group, v)
} catch (e) {
Toast.show(
'There was an issue syncing your preferences with the server',
)
logger.error('Failed to update preferences with server', {error: e})
}
}, },
[store, group], [mutate, labelGroup],
) )
return ( return (
<View style={[styles.contentLabelPref, pal.border]}> <View style={[styles.contentLabelPref, pal.border]}>
<View style={s.flex1}> <View style={s.flex1}>
<Text type="md-medium" style={[pal.text]}> <Text type="md-medium" style={[pal.text]}>
{CONFIGURABLE_LABEL_GROUPS[group].title} {CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
</Text> </Text>
{typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
<Text type="sm" style={[pal.textLight]}> <Text type="sm" style={[pal.textLight]}>
{CONFIGURABLE_LABEL_GROUPS[group].subtitle} {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
</Text> </Text>
)} )}
</View> </View>
{disabled ? (
{disabled || !visibility ? (
<Text type="sm-bold" style={pal.textLight}> <Text type="sm-bold" style={pal.textLight}>
Hide Hide
</Text> </Text>
) : ( ) : (
<SelectGroup <SelectGroup
current={store.preferences.contentLabels[group]} current={variables?.visibility || visibility}
onChange={onChange} onChange={onChange}
group={group} labelGroup={labelGroup}
/> />
)} )}
</View> </View>
@ -215,10 +221,10 @@ const ContentLabelPref = observer(function ContentLabelPrefImpl({
interface SelectGroupProps { interface SelectGroupProps {
current: LabelPreference current: LabelPreference
onChange: (v: LabelPreference) => void onChange: (v: LabelPreference) => void
group: keyof typeof CONFIGURABLE_LABEL_GROUPS labelGroup: ConfigurableLabelGroup
} }
function SelectGroup({current, onChange, group}: SelectGroupProps) { function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
return ( return (
<View style={styles.selectableBtns}> <View style={styles.selectableBtns}>
<SelectableBtn <SelectableBtn
@ -227,14 +233,14 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
label="Hide" label="Hide"
left left
onChange={onChange} onChange={onChange}
group={group} labelGroup={labelGroup}
/> />
<SelectableBtn <SelectableBtn
current={current} current={current}
value="warn" value="warn"
label="Warn" label="Warn"
onChange={onChange} onChange={onChange}
group={group} labelGroup={labelGroup}
/> />
<SelectableBtn <SelectableBtn
current={current} current={current}
@ -242,7 +248,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
label="Show" label="Show"
right right
onChange={onChange} onChange={onChange}
group={group} labelGroup={labelGroup}
/> />
</View> </View>
) )
@ -255,7 +261,7 @@ interface SelectableBtnProps {
left?: boolean left?: boolean
right?: boolean right?: boolean
onChange: (v: LabelPreference) => void onChange: (v: LabelPreference) => void
group: keyof typeof CONFIGURABLE_LABEL_GROUPS labelGroup: ConfigurableLabelGroup
} }
function SelectableBtn({ function SelectableBtn({
@ -265,7 +271,7 @@ function SelectableBtn({
left, left,
right, right,
onChange, onChange,
group, labelGroup,
}: SelectableBtnProps) { }: SelectableBtnProps) {
const pal = usePalette('default') const pal = usePalette('default')
const palPrimary = usePalette('inverted') const palPrimary = usePalette('inverted')
@ -281,7 +287,7 @@ function SelectableBtn({
onPress={() => onChange(value)} onPress={() => onChange(value)}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={value} accessibilityLabel={value}
accessibilityHint={`Set ${value} for ${group} content moderation policy`}> accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}>
<Text style={current === value ? palPrimary.text : pal.text}> <Text style={current === value ? palPrimary.text : pal.text}>
{label} {label}
</Text> </Text>

View File

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {useStores} from 'state/index'
import {navigate} from '../../../Navigation' import {navigate} from '../../../Navigation'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {useSetFeedViewPreferencesMutation} from '#/state/queries/preferences'
/** /**
* This utility component is only included in the test simulator * This utility component is only included in the test simulator
@ -15,10 +15,10 @@ import {useSessionApi} from '#/state/session'
const BTN = {height: 1, width: 1, backgroundColor: 'red'} const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() { export function TestCtrls() {
const store = useStores()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {logout, login} = useSessionApi() const {logout, login} = useSessionApi()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
const onPressSignInAlice = async () => { const onPressSignInAlice = async () => {
await login({ await login({
service: 'http://localhost:3000', service: 'http://localhost:3000',
@ -79,7 +79,7 @@ export function TestCtrls() {
/> />
<Pressable <Pressable
testID="e2eToggleMergefeed" testID="e2eToggleMergefeed"
onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} onPress={() => setFeedViewPref({lab_mergeFeedEnabled: true})}
accessibilityRole="button" accessibilityRole="button"
style={BTN} style={BTN}
/> />

View File

@ -4,7 +4,6 @@ import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Slider} from '@miblanchard/react-native-slider' import {Slider} from '@miblanchard/react-native-slider'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -16,21 +15,31 @@ import {CenteredView} from 'view/com/util/Views'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
} from '#/state/queries/preferences'
function RepliesThresholdInput({enabled}: {enabled: boolean}) { function RepliesThresholdInput({
const store = useStores() enabled,
initialValue,
}: {
enabled: boolean
initialValue: number
}) {
const pal = usePalette('default') const pal = usePalette('default')
const [value, setValue] = useState( const [value, setValue] = useState(initialValue)
store.preferences.homeFeed.hideRepliesByLikeCount, const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
)
const save = React.useMemo( const save = React.useMemo(
() => () =>
debounce( debounce(
threshold => threshold =>
store.preferences.setHomeFeedHideRepliesByLikeCount(threshold), setFeedViewPref({
hideRepliesByLikeCount: threshold,
}),
500, 500,
), // debouce for 500ms ), // debouce for 500ms
[store], [setFeedViewPref],
) )
return ( return (
@ -67,9 +76,15 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
navigation, navigation,
}: Props) { }: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {_} = useLingui() const {_} = useLingui()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const {data: preferences} = usePreferencesQuery()
const {mutate: setFeedViewPref, variables} =
useSetFeedViewPreferencesMutation()
const showReplies = !(
variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies
)
return ( return (
<CenteredView <CenteredView
@ -105,17 +120,20 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton <ToggleButton
testID="toggleRepliesBtn" testID="toggleRepliesBtn"
type="default-light" type="default-light"
label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'} label={showReplies ? 'Yes' : 'No'}
isSelected={!store.preferences.homeFeed.hideReplies} isSelected={showReplies}
onPress={store.preferences.toggleHomeFeedHideReplies} onPress={() =>
setFeedViewPref({
hideReplies: !(
variables?.hideReplies ??
preferences?.feedViewPrefs?.hideReplies
),
})
}
/> />
</View> </View>
<View <View
style={[ style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
pal.viewLight,
styles.card,
store.preferences.homeFeed.hideReplies && styles.dimmed,
]}>
<Text type="title-sm" style={[pal.text, s.pb5]}> <Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Reply Filters</Trans> <Trans>Reply Filters</Trans>
</Text> </Text>
@ -128,10 +146,19 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton <ToggleButton
type="default-light" type="default-light"
label="Followed users only" label="Followed users only"
isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed} isSelected={Boolean(
variables?.hideRepliesByUnfollowed ??
preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
)}
onPress={ onPress={
!store.preferences.homeFeed.hideReplies showReplies
? store.preferences.toggleHomeFeedHideRepliesByUnfollowed ? () =>
setFeedViewPref({
hideRepliesByUnfollowed: !(
variables?.hideRepliesByUnfollowed ??
preferences?.feedViewPrefs?.hideRepliesByUnfollowed
),
})
: undefined : undefined
} }
style={[s.mb10]} style={[s.mb10]}
@ -142,9 +169,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
feed. feed.
</Trans> </Trans>
</Text> </Text>
<RepliesThresholdInput {preferences && (
enabled={!store.preferences.homeFeed.hideReplies} <RepliesThresholdInput
/> enabled={showReplies}
initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
/>
)}
</View> </View>
<View style={[pal.viewLight, styles.card]}> <View style={[pal.viewLight, styles.card]}>
@ -158,9 +188,26 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
</Text> </Text>
<ToggleButton <ToggleButton
type="default-light" type="default-light"
label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'} label={
isSelected={!store.preferences.homeFeed.hideReposts} variables?.hideReposts ??
onPress={store.preferences.toggleHomeFeedHideReposts} preferences?.feedViewPrefs?.hideReposts
? 'No'
: 'Yes'
}
isSelected={
!(
variables?.hideReposts ??
preferences?.feedViewPrefs?.hideReposts
)
}
onPress={() =>
setFeedViewPref({
hideReposts: !(
variables?.hideReposts ??
preferences?.feedViewPrefs?.hideReposts
),
})
}
/> />
</View> </View>
@ -176,9 +223,26 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
</Text> </Text>
<ToggleButton <ToggleButton
type="default-light" type="default-light"
label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'} label={
isSelected={!store.preferences.homeFeed.hideQuotePosts} variables?.hideQuotePosts ??
onPress={store.preferences.toggleHomeFeedHideQuotePosts} preferences?.feedViewPrefs?.hideQuotePosts
? 'No'
: 'Yes'
}
isSelected={
!(
variables?.hideQuotePosts ??
preferences?.feedViewPrefs?.hideQuotePosts
)
}
onPress={() =>
setFeedViewPref({
hideQuotePosts: !(
variables?.hideQuotePosts ??
preferences?.feedViewPrefs?.hideQuotePosts
),
})
}
/> />
</View> </View>
@ -196,10 +260,25 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<ToggleButton <ToggleButton
type="default-light" type="default-light"
label={ label={
store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No' variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
? 'Yes'
: 'No'
}
isSelected={
!!(
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
)
}
onPress={() =>
setFeedViewPref({
lab_mergeFeedEnabled: !(
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled
),
})
} }
isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled}
onPress={store.preferences.toggleHomeFeedMergeFeedEnabled}
/> />
</View> </View>
</View> </View>

View File

@ -1,9 +1,14 @@
import React from 'react' import React from 'react'
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {
ActivityIndicator,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -14,15 +19,30 @@ import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views' import {CenteredView} from 'view/com/util/Views'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export const PreferencesThreads = observer(function PreferencesThreadsImpl({ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
navigation, navigation,
}: Props) { }: Props) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {_} = useLingui() const {_} = useLingui()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const {data: preferences} = usePreferencesQuery()
const {mutate: setThreadViewPrefs, variables} =
useSetThreadViewPreferencesMutation()
const prioritizeFollowedUsers = Boolean(
variables?.prioritizeFollowedUsers ??
preferences?.threadViewPrefs?.prioritizeFollowedUsers,
)
const treeViewEnabled = Boolean(
variables?.lab_treeViewEnabled ??
preferences?.threadViewPrefs?.lab_treeViewEnabled,
)
return ( return (
<CenteredView <CenteredView
@ -44,71 +64,79 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
</Text> </Text>
</View> </View>
<ScrollView> {preferences ? (
<View style={styles.cardsContainer}> <ScrollView>
<View style={[pal.viewLight, styles.card]}> <View style={styles.cardsContainer}>
<Text type="title-sm" style={[pal.text, s.pb5]}> <View style={[pal.viewLight, styles.card]}>
<Trans>Sort Replies</Trans> <Text type="title-sm" style={[pal.text, s.pb5]}>
</Text> <Trans>Sort Replies</Trans>
<Text style={[pal.text, s.pb10]}> </Text>
<Trans>Sort replies to the same post by:</Trans> <Text style={[pal.text, s.pb10]}>
</Text> <Trans>Sort replies to the same post by:</Trans>
<View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> </Text>
<RadioGroup <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}>
<RadioGroup
type="default-light"
items={[
{key: 'oldest', label: 'Oldest replies first'},
{key: 'newest', label: 'Newest replies first'},
{key: 'most-likes', label: 'Most-liked replies first'},
{key: 'random', label: 'Random (aka "Poster\'s Roulette")'},
]}
onSelect={key => setThreadViewPrefs({sort: key})}
initialSelection={preferences?.threadViewPrefs?.sort}
/>
</View>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<Trans>Prioritize Your Follows</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Show replies by people you follow before all other replies.
</Trans>
</Text>
<ToggleButton
type="default-light" type="default-light"
items={[ label={prioritizeFollowedUsers ? 'Yes' : 'No'}
{key: 'oldest', label: 'Oldest replies first'}, isSelected={prioritizeFollowedUsers}
{key: 'newest', label: 'Newest replies first'}, onPress={() =>
{key: 'most-likes', label: 'Most-liked replies first'}, setThreadViewPrefs({
{key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, prioritizeFollowedUsers: !prioritizeFollowedUsers,
]} })
onSelect={store.preferences.setThreadSort} }
initialSelection={store.preferences.thread.sort} />
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '}
<Trans>Threaded Mode</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Set this setting to "Yes" to show replies in a threaded view.
This is an experimental feature.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={treeViewEnabled ? 'Yes' : 'No'}
isSelected={treeViewEnabled}
onPress={() =>
setThreadViewPrefs({
lab_treeViewEnabled: !treeViewEnabled,
})
}
/> />
</View> </View>
</View> </View>
</ScrollView>
<View style={[pal.viewLight, styles.card]}> ) : (
<Text type="title-sm" style={[pal.text, s.pb5]}> <ActivityIndicator />
<Trans>Prioritize Your Follows</Trans> )}
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Show replies by people you follow before all other replies.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={
store.preferences.thread.prioritizeFollowedUsers ? 'Yes' : 'No'
}
isSelected={store.preferences.thread.prioritizeFollowedUsers}
onPress={store.preferences.togglePrioritizedFollowedUsers}
/>
</View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '}
<Trans>Threaded Mode</Trans>
</Text>
<Text style={[pal.text, s.pb10]}>
<Trans>
Set this setting to "Yes" to show replies in a threaded view.
This is an experimental feature.
</Trans>
</Text>
<ToggleButton
type="default-light"
label={
store.preferences.thread.lab_treeViewEnabled ? 'Yes' : 'No'
}
isSelected={!!store.preferences.thread.lab_treeViewEnabled}
onPress={store.preferences.toggleThreadTreeViewEnabled}
/>
</View>
</View>
</ScrollView>
<View <View
style={[ style={[

View File

@ -1,4 +1,4 @@
import React, {useCallback, useMemo} from 'react' import React from 'react'
import { import {
StyleSheet, StyleSheet,
View, View,
@ -8,26 +8,34 @@ import {
} from 'react-native' } from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {CommonNavigatorParams} from 'lib/routes/types' import {CommonNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from 'view/com/util/ViewHeader' import {ViewHeader} from 'view/com/util/ViewHeader'
import {ScrollView, CenteredView} from 'view/com/util/Views' import {ScrollView, CenteredView} from 'view/com/util/Views'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' import {NewFeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {FeedSourceModel} from 'state/models/content/feed-source'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {
usePreferencesQuery,
usePinFeedMutation,
useUnpinFeedMutation,
useSetSaveFeedsMutation,
usePreferencesQueryKey,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
const HITSLOP_TOP = { const HITSLOP_TOP = {
top: 20, top: 20,
@ -43,150 +51,178 @@ const HITSLOP_BOTTOM = {
} }
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export const SavedFeeds = withAuthRequired( export const SavedFeeds = withAuthRequired(function SavedFeedsImpl({}: Props) {
observer(function SavedFeedsImpl({}: Props) { const pal = usePalette('default')
const pal = usePalette('default') const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
const store = useStores() const {screen} = useAnalytics()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const setMinimalShellMode = useSetMinimalShellMode()
const {screen} = useAnalytics() const {data: preferences} = usePreferencesQuery()
const setMinimalShellMode = useSetMinimalShellMode()
const savedFeeds = useMemo(() => { useFocusEffect(
const model = new SavedFeedsModel(store) React.useCallback(() => {
model.refresh() screen('SavedFeeds')
return model setMinimalShellMode(false)
}, [store]) }, [screen, setMinimalShellMode]),
useFocusEffect( )
useCallback(() => {
screen('SavedFeeds')
setMinimalShellMode(false)
savedFeeds.refresh()
}, [screen, setMinimalShellMode, savedFeeds]),
)
return ( return (
<CenteredView <CenteredView
style={[ style={[
s.hContentRegion, s.hContentRegion,
pal.border, pal.border,
isTabletOrDesktop && styles.desktopContainer, isTabletOrDesktop && styles.desktopContainer,
]}> ]}>
<ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> <ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
<ScrollView style={s.flex1}> <ScrollView style={s.flex1}>
<View style={[pal.text, pal.border, styles.title]}> <View style={[pal.text, pal.border, styles.title]}>
<Text type="title" style={pal.text}> <Text type="title" style={pal.text}>
Pinned Feeds Pinned Feeds
</Text> </Text>
</View> </View>
{savedFeeds.hasLoaded ? ( {preferences?.feeds ? (
!savedFeeds.pinned.length ? ( !preferences.feeds.pinned.length ? (
<View <View
style={[ style={[
pal.border, pal.border,
isMobile && s.flex1, isMobile && s.flex1,
pal.viewLight, pal.viewLight,
styles.empty, styles.empty,
]}> ]}>
<Text type="lg" style={[pal.text]}> <Text type="lg" style={[pal.text]}>
You don't have any pinned feeds. You don't have any pinned feeds.
</Text> </Text>
</View> </View>
) : (
savedFeeds.pinned.map(feed => (
<ListItem
key={feed._reactKey}
savedFeeds={savedFeeds}
item={feed}
/>
))
)
) : ( ) : (
<ActivityIndicator style={{marginTop: 20}} /> preferences?.feeds?.pinned?.map(uri => (
)} <ListItem key={uri} feedUri={uri} isPinned />
<View style={[pal.text, pal.border, styles.title]}> ))
<Text type="title" style={pal.text}> )
Saved Feeds ) : (
</Text> <ActivityIndicator style={{marginTop: 20}} />
</View> )}
{savedFeeds.hasLoaded ? ( <View style={[pal.text, pal.border, styles.title]}>
!savedFeeds.unpinned.length ? ( <Text type="title" style={pal.text}>
<View Saved Feeds
style={[ </Text>
pal.border, </View>
isMobile && s.flex1, {preferences?.feeds ? (
pal.viewLight, !preferences.feeds.unpinned.length ? (
styles.empty, <View
]}> style={[
<Text type="lg" style={[pal.text]}> pal.border,
You don't have any saved feeds. isMobile && s.flex1,
</Text> pal.viewLight,
</View> styles.empty,
) : ( ]}>
savedFeeds.unpinned.map(feed => ( <Text type="lg" style={[pal.text]}>
<ListItem You don't have any saved feeds.
key={feed._reactKey} </Text>
savedFeeds={savedFeeds} </View>
item={feed}
/>
))
)
) : ( ) : (
<ActivityIndicator style={{marginTop: 20}} /> preferences.feeds.unpinned.map(uri => (
)} <ListItem key={uri} feedUri={uri} isPinned={false} />
))
)
) : (
<ActivityIndicator style={{marginTop: 20}} />
)}
<View style={styles.footerText}> <View style={styles.footerText}>
<Text type="sm" style={pal.textLight}> <Text type="sm" style={pal.textLight}>
Feeds are custom algorithms that users build with a little coding Feeds are custom algorithms that users build with a little coding
expertise.{' '} expertise.{' '}
<TextLink <TextLink
type="sm" type="sm"
style={pal.link} style={pal.link}
href="https://github.com/bluesky-social/feed-generator" href="https://github.com/bluesky-social/feed-generator"
text="See this guide" text="See this guide"
/>{' '} />{' '}
for more information. for more information.
</Text> </Text>
</View> </View>
<View style={{height: 100}} /> <View style={{height: 100}} />
</ScrollView> </ScrollView>
</CenteredView> </CenteredView>
) )
}), })
)
const ListItem = observer(function ListItemImpl({ const ListItem = observer(function ListItemImpl({
savedFeeds, feedUri,
item, isPinned,
}: { }: {
savedFeeds: SavedFeedsModel feedUri: string // uri
item: FeedSourceModel isPinned: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const isPinned = item.isPinned const queryClient = useQueryClient()
const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
useUnpinFeedMutation()
const {isPending: isMovePending, mutateAsync: setSavedFeeds} =
useSetSaveFeedsMutation()
const onTogglePinned = useCallback(() => { const onTogglePinned = React.useCallback(async () => {
Haptics.default() Haptics.default()
item.togglePin().catch(e => {
try {
if (isPinned) {
await unpinFeed({uri: feedUri})
} else {
await pinFeed({uri: feedUri})
}
} catch (e) {
Toast.show('There was an issue contacting the server') Toast.show('There was an issue contacting the server')
logger.error('Failed to toggle pinned feed', {error: e}) logger.error('Failed to toggle pinned feed', {error: e})
}) }
}, [item]) }, [feedUri, isPinned, pinFeed, unpinFeed])
const onPressUp = useCallback(
() => const onPressUp = React.useCallback(async () => {
savedFeeds.movePinnedFeed(item, 'up').catch(e => { if (!isPinned) return
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e}) const feeds = queryClient.getQueryData<UsePreferencesQueryResponse>(
}), usePreferencesQueryKey,
[savedFeeds, item], )?.feeds
) const pinned = feeds?.pinned ?? []
const onPressDown = useCallback( const index = pinned.indexOf(feedUri)
() =>
savedFeeds.movePinnedFeed(item, 'down').catch(e => { if (index === -1 || index === 0) return
Toast.show('There was an issue contacting the server') ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
logger.error('Failed to set pinned feed order', {error: e})
}), try {
[savedFeeds, item], await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
) track('CustomFeed:Reorder', {
uri: feedUri,
index: pinned.indexOf(feedUri),
})
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}
}, [feedUri, isPinned, queryClient, setSavedFeeds])
const onPressDown = React.useCallback(async () => {
if (!isPinned) return
const feeds = queryClient.getQueryData<UsePreferencesQueryResponse>(
usePreferencesQueryKey,
)?.feeds
const pinned = feeds?.pinned ?? []
const index = pinned.indexOf(feedUri)
if (index === -1 || index >= pinned.length - 1) return
;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
try {
await setSavedFeeds({saved: feeds?.saved ?? [], pinned})
track('CustomFeed:Reorder', {
uri: feedUri,
index: pinned.indexOf(feedUri),
})
} catch (e) {
Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e})
}
}, [feedUri, isPinned, queryClient, setSavedFeeds])
return ( return (
<Pressable <Pressable
@ -195,6 +231,7 @@ const ListItem = observer(function ListItemImpl({
{isPinned ? ( {isPinned ? (
<View style={styles.webArrowButtonsContainer}> <View style={styles.webArrowButtonsContainer}>
<TouchableOpacity <TouchableOpacity
disabled={isMovePending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressUp} onPress={onPressUp}
hitSlop={HITSLOP_TOP}> hitSlop={HITSLOP_TOP}>
@ -205,6 +242,7 @@ const ListItem = observer(function ListItemImpl({
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
disabled={isMovePending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressDown} onPress={onPressDown}
hitSlop={HITSLOP_BOTTOM}> hitSlop={HITSLOP_BOTTOM}>
@ -212,13 +250,14 @@ const ListItem = observer(function ListItemImpl({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : null} ) : null}
<FeedSourceCard <NewFeedSourceCard
key={item.uri} key={feedUri}
item={item} feedUri={feedUri}
showSaveBtn
style={styles.noBorder} style={styles.noBorder}
showSaveBtn
/> />
<TouchableOpacity <TouchableOpacity
disabled={isPinPending || isUnpinPending}
accessibilityRole="button" accessibilityRole="button"
hitSlop={10} hitSlop={10}
onPress={onTogglePinned}> onPress={onTogglePinned}>

View File

@ -59,6 +59,7 @@ import {
} from '#/state/preferences' } from '#/state/preferences'
import {useSession, useSessionApi, SessionAccount} from '#/state/session' import {useSession, useSessionApi, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useClearPreferencesMutation} from '#/state/queries/preferences'
// TEMPORARY (APP-700) // TEMPORARY (APP-700)
// remove after backend testing finishes // remove after backend testing finishes
@ -153,6 +154,7 @@ export const SettingsScreen = withAuthRequired(
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {isSwitchingAccounts, accounts, currentAccount} = useSession() const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const {clearCurrentAccount} = useSessionApi() const {clearCurrentAccount} = useSessionApi()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const primaryBg = useCustomPalette<ViewStyle>({ const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0}, light: {backgroundColor: colors.blue0},
@ -219,9 +221,8 @@ export const SettingsScreen = withAuthRequired(
}, [openModal]) }, [openModal])
const onPressResetPreferences = React.useCallback(async () => { const onPressResetPreferences = React.useCallback(async () => {
await store.preferences.reset() clearPreferences()
Toast.show('Preferences reset') }, [clearPreferences])
}, [store])
const onPressResetOnboarding = React.useCallback(async () => { const onPressResetOnboarding = React.useCallback(async () => {
onboardingDispatch({type: 'start'}) onboardingDispatch({type: 'start'})
@ -300,7 +301,7 @@ export const SettingsScreen = withAuthRequired(
</View> </View>
<View style={[styles.infoLine]}> <View style={[styles.infoLine]}>
<Text type="lg-medium" style={pal.text}> <Text type="lg-medium" style={pal.text}>
<Trans>Birthday: </Trans> <Trans>Birthday:</Trans>{' '}
</Text> </Text>
<Link onPress={() => openModal({name: 'birth-date-settings'})}> <Link onPress={() => openModal({name: 'birth-date-settings'})}>
<Text type="lg" style={pal.link}> <Text type="lg" style={pal.link}>