Misc cleanup (#1925)
* Remove unused prefs * Cleanup * Remove my-follows cache * Replace moderationOpts in ProfileCard comp * Replace moderationOpts in FeedSlice * Remove preferences modelzio/stable
parent
e749f2f3a5
commit
0de8d40981
|
@ -1,137 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
||||
const MAX_SYNC_PAGES = 10
|
||||
const SYNC_TTL = 60e3 * 10 // 10 minutes
|
||||
|
||||
type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
|
||||
|
||||
export enum FollowState {
|
||||
Following,
|
||||
NotFollowing,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface FollowInfo {
|
||||
did: string
|
||||
followRecordUri: string | undefined
|
||||
handle: string
|
||||
displayName: string | undefined
|
||||
avatar: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* This model is used to maintain a synced local cache of the user's
|
||||
* follows. It should be periodically refreshed and updated any time
|
||||
* the user makes a change to their follows.
|
||||
*/
|
||||
export class MyFollowsCache {
|
||||
// data
|
||||
byDid: Record<string, FollowInfo> = {}
|
||||
lastSync = 0
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
clear() {
|
||||
this.byDid = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs a subset of the user's follows
|
||||
* for performance reasons, caps out at 1000 follows
|
||||
*/
|
||||
syncIfNeeded = bundleAsync(async () => {
|
||||
if (this.lastSync > Date.now() - SYNC_TTL) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursor
|
||||
for (let i = 0; i < MAX_SYNC_PAGES; i++) {
|
||||
const res: GetFollows.Response = await this.rootStore.agent.getFollows({
|
||||
actor: this.rootStore.me.did,
|
||||
cursor,
|
||||
limit: 100,
|
||||
})
|
||||
res.data.follows = res.data.follows.filter(
|
||||
profile =>
|
||||
!moderateProfile(profile, this.rootStore.preferences.moderationOpts)
|
||||
.account.filter,
|
||||
)
|
||||
this.hydrateMany(res.data.follows)
|
||||
if (!res.data.cursor) {
|
||||
break
|
||||
}
|
||||
cursor = res.data.cursor
|
||||
}
|
||||
|
||||
this.lastSync = Date.now()
|
||||
})
|
||||
|
||||
getFollowState(did: string): FollowState {
|
||||
if (typeof this.byDid[did] === 'undefined') {
|
||||
return FollowState.Unknown
|
||||
}
|
||||
if (typeof this.byDid[did].followRecordUri === 'string') {
|
||||
return FollowState.Following
|
||||
}
|
||||
return FollowState.NotFollowing
|
||||
}
|
||||
|
||||
async fetchFollowState(did: string): Promise<FollowState> {
|
||||
// TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
|
||||
const res = await this.rootStore.agent.getProfile({actor: did})
|
||||
this.hydrate(did, res.data)
|
||||
return this.getFollowState(did)
|
||||
}
|
||||
|
||||
getFollowUri(did: string): string {
|
||||
const v = this.byDid[did]
|
||||
if (v && typeof v.followRecordUri === 'string') {
|
||||
return v.followRecordUri
|
||||
}
|
||||
throw new Error('Not a followed user')
|
||||
}
|
||||
|
||||
addFollow(did: string, info: FollowInfo) {
|
||||
this.byDid[did] = info
|
||||
}
|
||||
|
||||
removeFollow(did: string) {
|
||||
if (this.byDid[did]) {
|
||||
this.byDid[did].followRecordUri = undefined
|
||||
}
|
||||
}
|
||||
|
||||
hydrate(did: string, profile: Profile) {
|
||||
this.byDid[did] = {
|
||||
did,
|
||||
followRecordUri: profile.viewer?.following,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
hydrateMany(profiles: Profile[]) {
|
||||
for (const profile of profiles) {
|
||||
this.hydrate(profile.did, profile)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import sampleSize from 'lodash.samplesize'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
||||
export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & {
|
||||
followers: AppBskyActorDefs.ProfileView[]
|
||||
}
|
||||
|
||||
export type ProfileViewFollows = AppBskyActorDefs.ProfileView & {
|
||||
follows: AppBskyActorDefs.ProfileViewBasic[]
|
||||
}
|
||||
|
||||
export class FoafsModel {
|
||||
isLoading = false
|
||||
hasData = false
|
||||
sources: string[] = []
|
||||
foafs: Map<string, ProfileViewFollows> = new Map() // FOAF stands for Friend of a Friend
|
||||
popular: RefWithInfoAndFollowers[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
if (this.popular.length > 0) {
|
||||
return true
|
||||
}
|
||||
for (const foaf of this.foafs.values()) {
|
||||
if (foaf.follows.length) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fetch = bundleAsync(async () => {
|
||||
try {
|
||||
this.isLoading = true
|
||||
|
||||
// fetch some of the user's follows
|
||||
await this.rootStore.me.follows.syncIfNeeded()
|
||||
|
||||
// grab 10 of the users followed by the user
|
||||
runInAction(() => {
|
||||
this.sources = sampleSize(
|
||||
Object.keys(this.rootStore.me.follows.byDid),
|
||||
10,
|
||||
)
|
||||
})
|
||||
if (this.sources.length === 0) {
|
||||
return
|
||||
}
|
||||
runInAction(() => {
|
||||
this.foafs.clear()
|
||||
this.popular.length = 0
|
||||
})
|
||||
|
||||
// fetch their profiles
|
||||
const profiles = await this.rootStore.agent.getProfiles({
|
||||
actors: this.sources,
|
||||
})
|
||||
|
||||
// fetch their follows
|
||||
const results = await Promise.allSettled(
|
||||
this.sources.map(source =>
|
||||
this.rootStore.agent.getFollows({actor: source}),
|
||||
),
|
||||
)
|
||||
|
||||
// store the follows and construct a "most followed" set
|
||||
const popular: RefWithInfoAndFollowers[] = []
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
if (res.status === 'fulfilled') {
|
||||
this.rootStore.me.follows.hydrateMany(res.value.data.follows)
|
||||
}
|
||||
const profile = profiles.data.profiles[i]
|
||||
const source = this.sources[i]
|
||||
if (res.status === 'fulfilled' && profile) {
|
||||
// filter out inappropriate suggestions
|
||||
res.value.data.follows = res.value.data.follows.filter(follow => {
|
||||
const viewer = follow.viewer
|
||||
if (viewer) {
|
||||
if (
|
||||
viewer.following ||
|
||||
viewer.muted ||
|
||||
viewer.mutedByList ||
|
||||
viewer.blockedBy ||
|
||||
viewer.blocking
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (follow.did === this.rootStore.me.did) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.foafs.set(source, {
|
||||
...profile,
|
||||
follows: res.value.data.follows,
|
||||
})
|
||||
})
|
||||
for (const follow of res.value.data.follows) {
|
||||
let item = popular.find(p => p.did === follow.did)
|
||||
if (!item) {
|
||||
item = {...follow, followers: []}
|
||||
popular.push(item)
|
||||
}
|
||||
item.followers.push(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popular.sort((a, b) => b.followers.length - a.followers.length)
|
||||
runInAction(() => {
|
||||
this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20)
|
||||
})
|
||||
this.hasData = true
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch FOAFs', e)
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export type SuggestedActor =
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileView
|
||||
|
||||
export class SuggestedActorsModel {
|
||||
// state
|
||||
pageSize = PAGE_SIZE
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
loadMoreCursor: string | undefined = undefined
|
||||
error = ''
|
||||
hasMore = false
|
||||
lastInsertedAtIndex = -1
|
||||
|
||||
// data
|
||||
suggestions: SuggestedActor[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
|
||||
if (opts?.pageSize) {
|
||||
this.pageSize = opts.pageSize
|
||||
}
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.suggestions.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async refresh() {
|
||||
return this.loadMore(true)
|
||||
}
|
||||
|
||||
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||
if (replace) {
|
||||
this.hasMore = true
|
||||
this.loadMoreCursor = undefined
|
||||
}
|
||||
if (!this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading(replace)
|
||||
try {
|
||||
const res = await this.rootStore.agent.app.bsky.actor.getSuggestions({
|
||||
limit: 25,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
let {actors, cursor} = res.data
|
||||
actors = actors.filter(
|
||||
actor =>
|
||||
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
|
||||
.account.filter,
|
||||
)
|
||||
this.rootStore.me.follows.hydrateMany(actors)
|
||||
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
this.suggestions = []
|
||||
}
|
||||
this.loadMoreCursor = cursor
|
||||
this.hasMore = !!cursor
|
||||
this.suggestions = this.suggestions.concat(
|
||||
actors.filter(actor => {
|
||||
const viewer = actor.viewer
|
||||
if (viewer) {
|
||||
if (
|
||||
viewer.following ||
|
||||
viewer.muted ||
|
||||
viewer.mutedByList ||
|
||||
viewer.blockedBy ||
|
||||
viewer.blocking
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (actor.did === this.rootStore.me.did) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
|
||||
// fetch suggestions
|
||||
const res =
|
||||
await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
actor: actor,
|
||||
})
|
||||
const {suggestions: moreSuggestions} = res.data
|
||||
this.rootStore.me.follows.hydrateMany(moreSuggestions)
|
||||
// dedupe
|
||||
const toInsert = moreSuggestions.filter(
|
||||
s => !this.suggestions.find(s2 => s2.did === s.did),
|
||||
)
|
||||
// insert
|
||||
this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
|
||||
// update index
|
||||
this.lastInsertedAtIndex = indexToInsertAt
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
logger.error('Failed to fetch suggested actors', {error: err})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
|
||||
type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
|
||||
|
||||
export class UserAutocompleteModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isActive = false
|
||||
prefix = ''
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
knownHandles: Set<string> = new Set()
|
||||
_suggestions: ProfileViewBasic[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
knownHandles: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get follows(): ProfileViewBasic[] {
|
||||
return Object.values(this.rootStore.me.follows.byDid).map(item => ({
|
||||
did: item.did,
|
||||
handle: item.handle,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar,
|
||||
}))
|
||||
}
|
||||
|
||||
get suggestions(): ProfileViewBasic[] {
|
||||
if (!this.isActive) {
|
||||
return []
|
||||
}
|
||||
return this._suggestions
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
this.isLoading = true
|
||||
await this.rootStore.me.follows.syncIfNeeded()
|
||||
runInAction(() => {
|
||||
for (const did in this.rootStore.me.follows.byDid) {
|
||||
const info = this.rootStore.me.follows.byDid[did]
|
||||
if (!isInvalidHandle(info.handle)) {
|
||||
this.knownHandles.add(info.handle)
|
||||
}
|
||||
}
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
setActive(v: boolean) {
|
||||
this.isActive = v
|
||||
}
|
||||
|
||||
async setPrefix(prefix: string) {
|
||||
const origPrefix = prefix.trim().toLocaleLowerCase()
|
||||
this.prefix = origPrefix
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
if (this.prefix) {
|
||||
if (this.prefix !== origPrefix) {
|
||||
return // another prefix was set before we got our chance
|
||||
}
|
||||
|
||||
// reset to follow results
|
||||
this._computeSuggestions([])
|
||||
|
||||
// ask backend
|
||||
const res = await this.rootStore.agent.searchActorsTypeahead({
|
||||
term: this.prefix,
|
||||
limit: 8,
|
||||
})
|
||||
this._computeSuggestions(res.data.actors)
|
||||
|
||||
// update known handles
|
||||
runInAction(() => {
|
||||
for (const u of res.data.actors) {
|
||||
this.knownHandles.add(u.handle)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
runInAction(() => {
|
||||
this._computeSuggestions([])
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
// =
|
||||
|
||||
_computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
|
||||
if (this.prefix) {
|
||||
const items: ProfileViewBasic[] = []
|
||||
for (const item of this.follows) {
|
||||
if (prefixMatch(this.prefix, item)) {
|
||||
items.push(item)
|
||||
}
|
||||
if (items.length >= 8) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const item of searchRes) {
|
||||
if (!items.find(item2 => item2.handle === item.handle)) {
|
||||
items.push({
|
||||
did: item.did,
|
||||
handle: item.handle,
|
||||
displayName: item.displayName,
|
||||
avatar: item.avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
this._suggestions = items
|
||||
} else {
|
||||
this._suggestions = this.follows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
|
||||
if (info.handle.includes(prefix)) {
|
||||
return true
|
||||
}
|
||||
if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedPost as FeedPost,
|
||||
AppBskyFeedDefs,
|
||||
RichText,
|
||||
moderatePost,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
|
||||
export class PostsFeedItemModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
post: PostView
|
||||
postRecord?: FeedPost.Record
|
||||
reply?: FeedViewPost['reply']
|
||||
reason?: FeedViewPost['reason']
|
||||
richText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
_reactKey: string,
|
||||
v: FeedViewPost,
|
||||
) {
|
||||
this._reactKey = _reactKey
|
||||
this.post = v.post
|
||||
if (FeedPost.isRecord(this.post.record)) {
|
||||
const valid = FeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
hackAddDeletedEmbed(this.post)
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
logger.warn('Received an invalid app.bsky.feed.post record', {
|
||||
error: valid.error,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
logger.warn(
|
||||
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
|
||||
{record: this.post.record},
|
||||
)
|
||||
}
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
get uri() {
|
||||
return this.post.uri
|
||||
}
|
||||
|
||||
get parentUri() {
|
||||
return this.postRecord?.reply?.parent.uri
|
||||
}
|
||||
|
||||
get rootUri(): string {
|
||||
if (typeof this.postRecord?.reply?.root.uri === 'string') {
|
||||
return this.postRecord?.reply?.root.uri
|
||||
}
|
||||
return this.post.uri
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return moderatePost(this.post, this.rootStore.preferences.moderationOpts)
|
||||
}
|
||||
|
||||
copy(v: FeedViewPost) {
|
||||
this.post = v.post
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
}
|
||||
|
||||
copyMetrics(v: FeedViewPost) {
|
||||
this.post.replyCount = v.post.replyCount
|
||||
this.post.repostCount = v.post.repostCount
|
||||
this.post.likeCount = v.post.likeCount
|
||||
this.post.viewer = v.post.viewer
|
||||
}
|
||||
|
||||
get reasonRepost(): ReasonRepost | undefined {
|
||||
if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
|
||||
return this.reason as ReasonRepost
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
try {
|
||||
if (this.post.viewer.like) {
|
||||
// unlike
|
||||
const url = this.post.viewer.like
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) - 1
|
||||
this.post.viewer!.like = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteLike(url),
|
||||
)
|
||||
track('Post:Unlike')
|
||||
} else {
|
||||
// like
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) + 1
|
||||
this.post.viewer!.like = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.like(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.like = res.uri
|
||||
},
|
||||
)
|
||||
track('Post:Like')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle like', {error})
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
try {
|
||||
if (this.post.viewer?.repost) {
|
||||
// unrepost
|
||||
const url = this.post.viewer.repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) - 1
|
||||
this.post.viewer!.repost = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteRepost(url),
|
||||
)
|
||||
track('Post:Unrepost')
|
||||
} else {
|
||||
// repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) + 1
|
||||
this.post.viewer!.repost = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.repost(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.repost = res.uri
|
||||
},
|
||||
)
|
||||
track('Post:Repost')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle repost', {error})
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
try {
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete post', {error})
|
||||
} finally {
|
||||
track('Post:Delete')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ import {
|
|||
ComAtprotoServerListAppPasswords,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import {MyFollowsCache} from './cache/my-follows'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
|
@ -18,7 +17,6 @@ export class MeModel {
|
|||
avatar: string = ''
|
||||
followsCount: number | undefined
|
||||
followersCount: number | undefined
|
||||
follows: MyFollowsCache
|
||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
|
||||
lastProfileStateUpdate = Date.now()
|
||||
|
@ -33,11 +31,9 @@ export class MeModel {
|
|||
{rootStore: false, serialize: false, hydrate: false},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.follows = new MyFollowsCache(this.rootStore)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.follows.clear()
|
||||
this.rootStore.profiles.cache.clear()
|
||||
this.rootStore.posts.cache.clear()
|
||||
this.did = ''
|
||||
|
|
|
@ -15,7 +15,6 @@ import {ProfilesCache} from './cache/profiles-view'
|
|||
import {PostsCache} from './cache/posts'
|
||||
import {LinkMetasCache} from './cache/link-metas'
|
||||
import {MeModel} from './me'
|
||||
import {PreferencesModel} from './ui/preferences'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {ImageSizesCache} from './cache/image-sizes'
|
||||
import {reset as resetNavigation} from '../../Navigation'
|
||||
|
@ -39,7 +38,6 @@ export class RootStoreModel {
|
|||
appInfo?: AppInfo
|
||||
session = new SessionModel(this)
|
||||
shell = new ShellUiModel(this)
|
||||
preferences = new PreferencesModel(this)
|
||||
me = new MeModel(this)
|
||||
handleResolutions = new HandleResolutionsCache()
|
||||
profiles = new ProfilesCache(this)
|
||||
|
@ -64,7 +62,6 @@ export class RootStoreModel {
|
|||
return {
|
||||
appInfo: this.appInfo,
|
||||
me: this.me.serialize(),
|
||||
preferences: this.preferences.serialize(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,9 +76,6 @@ export class RootStoreModel {
|
|||
if (hasProp(v, 'me')) {
|
||||
this.me.hydrate(v.me)
|
||||
}
|
||||
if (hasProp(v, 'preferences')) {
|
||||
this.preferences.hydrate(v.preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {
|
||||
LabelPreference as APILabelPreference,
|
||||
BskyThreadViewPreference,
|
||||
} from '@atproto/api'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {ModerationOpts} from '@atproto/api'
|
||||
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
export type LabelPreference = APILabelPreference | 'show'
|
||||
export type ThreadViewPreference = BskyThreadViewPreference & {
|
||||
lab_treeViewEnabled?: boolean | undefined
|
||||
}
|
||||
|
||||
export class LabelPreferencesModel {
|
||||
nsfw: LabelPreference = 'hide'
|
||||
nudity: LabelPreference = 'warn'
|
||||
suggestive: LabelPreference = 'warn'
|
||||
gore: LabelPreference = 'warn'
|
||||
hate: LabelPreference = 'hide'
|
||||
spam: LabelPreference = 'hide'
|
||||
impersonation: LabelPreference = 'warn'
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true})
|
||||
}
|
||||
}
|
||||
|
||||
export class PreferencesModel {
|
||||
contentLabels = new LabelPreferencesModel()
|
||||
savedFeeds: string[] = []
|
||||
pinnedFeeds: string[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {}, {autoBind: true})
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
contentLabels: this.contentLabels,
|
||||
savedFeeds: this.savedFeeds,
|
||||
pinnedFeeds: this.pinnedFeeds,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The function hydrates an object with properties related to content languages, labels, saved feeds,
|
||||
* and pinned feeds that it gets from the parameter `v` (probably local storage)
|
||||
* @param {unknown} v - the data object to hydrate from
|
||||
*/
|
||||
hydrate(v: unknown) {
|
||||
if (isObj(v)) {
|
||||
// check if content labels in preferences exist, then hydrate
|
||||
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
|
||||
Object.assign(this.contentLabels, v.contentLabels)
|
||||
}
|
||||
// check if saved feeds in preferences, then hydrate
|
||||
if (
|
||||
hasProp(v, 'savedFeeds') &&
|
||||
Array.isArray(v.savedFeeds) &&
|
||||
typeof v.savedFeeds.every(item => typeof item === 'string')
|
||||
) {
|
||||
this.savedFeeds = v.savedFeeds
|
||||
}
|
||||
// check if pinned feeds in preferences exist, then hydrate
|
||||
if (
|
||||
hasProp(v, 'pinnedFeeds') &&
|
||||
Array.isArray(v.pinnedFeeds) &&
|
||||
typeof v.pinnedFeeds.every(item => typeof item === 'string')
|
||||
) {
|
||||
this.pinnedFeeds = v.pinnedFeeds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// moderation
|
||||
// =
|
||||
|
||||
/**
|
||||
* @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead
|
||||
*/
|
||||
get moderationOpts(): ModerationOpts {
|
||||
return {
|
||||
userDid: this.rootStore.session.currentSession?.did || '',
|
||||
adultContentEnabled: false,
|
||||
labels: {
|
||||
// TEMP translate old settings until this UI can be migrated -prf
|
||||
porn: tempfixLabelPref(this.contentLabels.nsfw),
|
||||
sexual: tempfixLabelPref(this.contentLabels.suggestive),
|
||||
nudity: tempfixLabelPref(this.contentLabels.nudity),
|
||||
nsfl: tempfixLabelPref(this.contentLabels.gore),
|
||||
corpse: tempfixLabelPref(this.contentLabels.gore),
|
||||
gore: tempfixLabelPref(this.contentLabels.gore),
|
||||
torture: tempfixLabelPref(this.contentLabels.gore),
|
||||
'self-harm': tempfixLabelPref(this.contentLabels.gore),
|
||||
'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
|
||||
'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
|
||||
'intolerant-sexual-orientation': tempfixLabelPref(
|
||||
this.contentLabels.hate,
|
||||
),
|
||||
'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
|
||||
intolerant: tempfixLabelPref(this.contentLabels.hate),
|
||||
'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
|
||||
spam: tempfixLabelPref(this.contentLabels.spam),
|
||||
impersonation: tempfixLabelPref(this.contentLabels.impersonation),
|
||||
scam: 'warn',
|
||||
},
|
||||
labelers: [
|
||||
{
|
||||
labeler: {
|
||||
did: '',
|
||||
displayName: 'Bluesky Social',
|
||||
},
|
||||
labels: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
// TODO do we need this?
|
||||
function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
|
||||
if (pref === 'show') {
|
||||
return 'ignore'
|
||||
}
|
||||
return pref
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
import React from 'react'
|
||||
import {View, StyleSheet, ActivityIndicator} from 'react-native'
|
||||
import {ProfileModeration} from '@atproto/api'
|
||||
import {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
|
||||
import {Button} from '#/view/com/util/forms/Button'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {SuggestedActor} from 'state/models/discovery/suggested-actors'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {s} from 'lib/styles'
|
||||
|
@ -21,7 +20,7 @@ import {
|
|||
import {logger} from '#/logger'
|
||||
|
||||
type Props = {
|
||||
profile: SuggestedActor
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
dataUpdatedAt: number
|
||||
moderation: ProfileModeration
|
||||
onFollowStateChange: (props: {
|
||||
|
@ -67,7 +66,7 @@ export function ProfileCard({
|
|||
onFollowStateChange,
|
||||
moderation,
|
||||
}: {
|
||||
profile: Shadow<SuggestedActor>
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
moderation: ProfileModeration
|
||||
onFollowStateChange: (props: {
|
||||
did: string
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
FeedParams,
|
||||
usePostFeedQuery,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
|
@ -71,6 +72,7 @@ export function Feed({
|
|||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const opts = React.useMemo(() => ({enabled}), [enabled])
|
||||
const {
|
||||
data,
|
||||
|
@ -115,7 +117,7 @@ export function Feed({
|
|||
|
||||
const feedItems = React.useMemo(() => {
|
||||
let arr: any[] = []
|
||||
if (isFetched) {
|
||||
if (isFetched && moderationOpts) {
|
||||
if (isError && isEmpty) {
|
||||
arr = arr.concat([ERROR_ITEM])
|
||||
}
|
||||
|
@ -133,7 +135,7 @@ export function Feed({
|
|||
arr.push(LOADING_ITEM)
|
||||
}
|
||||
return arr
|
||||
}, [isFetched, isError, isEmpty, data])
|
||||
}, [isFetched, isError, isEmpty, data, moderationOpts])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
@ -195,7 +197,14 @@ export function Feed({
|
|||
} else if (item === LOADING_ITEM) {
|
||||
return <PostFeedLoadingPlaceholder />
|
||||
}
|
||||
return <FeedSlice slice={item} dataUpdatedAt={dataUpdatedAt} />
|
||||
return (
|
||||
<FeedSlice
|
||||
slice={item}
|
||||
dataUpdatedAt={dataUpdatedAt}
|
||||
// we check for this before creating the feedItems array
|
||||
moderationOpts={moderationOpts!}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[
|
||||
feed,
|
||||
|
@ -204,6 +213,7 @@ export function Feed({
|
|||
onPressTryAgain,
|
||||
onPressRetryLoadMore,
|
||||
renderEmptyState,
|
||||
moderationOpts,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
@ -2,30 +2,28 @@ import React from 'react'
|
|||
import {StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FeedPostSlice} from '#/state/queries/post-feed'
|
||||
import {AtUri, moderatePost} from '@atproto/api'
|
||||
import {AtUri, moderatePost, ModerationOpts} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import Svg, {Circle, Line} from 'react-native-svg'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useStores} from '#/state'
|
||||
|
||||
export const FeedSlice = observer(function FeedSliceImpl({
|
||||
slice,
|
||||
dataUpdatedAt,
|
||||
ignoreFilterFor,
|
||||
moderationOpts,
|
||||
}: {
|
||||
slice: FeedPostSlice
|
||||
dataUpdatedAt: number
|
||||
ignoreFilterFor?: string
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const store = useStores()
|
||||
const moderations = React.useMemo(() => {
|
||||
return slice.items.map(item =>
|
||||
moderatePost(item.post, store.preferences.moderationOpts),
|
||||
)
|
||||
}, [slice, store.preferences.moderationOpts])
|
||||
return slice.items.map(item => moderatePost(item.post, moderationOpts))
|
||||
}, [slice, moderationOpts])
|
||||
|
||||
// apply moderation filter
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
|
|
|
@ -11,7 +11,6 @@ import {Text} from '../util/text/Text'
|
|||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {FollowButton} from './FollowButton'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
|
@ -158,19 +157,26 @@ const FollowersList = observer(function FollowersListImpl({
|
|||
}: {
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
if (!followers?.length) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const followersWithMods = React.useMemo(() => {
|
||||
if (!followers || !moderationOpts) {
|
||||
return []
|
||||
}
|
||||
|
||||
return followers
|
||||
.map(f => ({
|
||||
f,
|
||||
mod: moderateProfile(f, moderationOpts),
|
||||
}))
|
||||
.filter(({mod}) => !mod.account.filter)
|
||||
}, [followers, moderationOpts])
|
||||
|
||||
if (!followersWithMods?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const followersWithMods = followers
|
||||
.map(f => ({
|
||||
f,
|
||||
mod: moderateProfile(f, store.preferences.moderationOpts),
|
||||
}))
|
||||
.filter(({mod}) => !mod.account.filter)
|
||||
|
||||
return (
|
||||
<View style={styles.followedBy}>
|
||||
<Text
|
||||
|
|
Loading…
Reference in New Issue