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
parent
c8c308e31e
commit
05b728fffc
|
@ -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'],
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -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[]
|
|
||||||
}
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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')],
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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={[
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
Loading…
Reference in New Issue