Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Misc cleanup (#1925)

* Remove unused prefs

* Cleanup

* Remove my-follows cache

* Replace moderationOpts in ProfileCard comp

* Replace moderationOpts in FeedSlice

* Remove preferences model

authored by

Eric Bailey and committed by
GitHub
0de8d409 e749f2f3

+37 -907
-137
src/state/models/cache/my-follows.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import { 3 - AppBskyActorDefs, 4 - AppBskyGraphGetFollows as GetFollows, 5 - moderateProfile, 6 - } from '@atproto/api' 7 - import {RootStoreModel} from '../root-store' 8 - import {bundleAsync} from 'lib/async/bundle' 9 - 10 - const MAX_SYNC_PAGES = 10 11 - const SYNC_TTL = 60e3 * 10 // 10 minutes 12 - 13 - type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView 14 - 15 - export enum FollowState { 16 - Following, 17 - NotFollowing, 18 - Unknown, 19 - } 20 - 21 - export interface FollowInfo { 22 - did: string 23 - followRecordUri: string | undefined 24 - handle: string 25 - displayName: string | undefined 26 - avatar: string | undefined 27 - } 28 - 29 - /** 30 - * This model is used to maintain a synced local cache of the user's 31 - * follows. It should be periodically refreshed and updated any time 32 - * the user makes a change to their follows. 33 - */ 34 - export class MyFollowsCache { 35 - // data 36 - byDid: Record<string, FollowInfo> = {} 37 - lastSync = 0 38 - 39 - constructor(public rootStore: RootStoreModel) { 40 - makeAutoObservable( 41 - this, 42 - { 43 - rootStore: false, 44 - }, 45 - {autoBind: true}, 46 - ) 47 - } 48 - 49 - // public api 50 - // = 51 - 52 - clear() { 53 - this.byDid = {} 54 - } 55 - 56 - /** 57 - * Syncs a subset of the user's follows 58 - * for performance reasons, caps out at 1000 follows 59 - */ 60 - syncIfNeeded = bundleAsync(async () => { 61 - if (this.lastSync > Date.now() - SYNC_TTL) { 62 - return 63 - } 64 - 65 - let cursor 66 - for (let i = 0; i < MAX_SYNC_PAGES; i++) { 67 - const res: GetFollows.Response = await this.rootStore.agent.getFollows({ 68 - actor: this.rootStore.me.did, 69 - cursor, 70 - limit: 100, 71 - }) 72 - res.data.follows = res.data.follows.filter( 73 - profile => 74 - !moderateProfile(profile, this.rootStore.preferences.moderationOpts) 75 - .account.filter, 76 - ) 77 - this.hydrateMany(res.data.follows) 78 - if (!res.data.cursor) { 79 - break 80 - } 81 - cursor = res.data.cursor 82 - } 83 - 84 - this.lastSync = Date.now() 85 - }) 86 - 87 - getFollowState(did: string): FollowState { 88 - if (typeof this.byDid[did] === 'undefined') { 89 - return FollowState.Unknown 90 - } 91 - if (typeof this.byDid[did].followRecordUri === 'string') { 92 - return FollowState.Following 93 - } 94 - return FollowState.NotFollowing 95 - } 96 - 97 - async fetchFollowState(did: string): Promise<FollowState> { 98 - // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf 99 - const res = await this.rootStore.agent.getProfile({actor: did}) 100 - this.hydrate(did, res.data) 101 - return this.getFollowState(did) 102 - } 103 - 104 - getFollowUri(did: string): string { 105 - const v = this.byDid[did] 106 - if (v && typeof v.followRecordUri === 'string') { 107 - return v.followRecordUri 108 - } 109 - throw new Error('Not a followed user') 110 - } 111 - 112 - addFollow(did: string, info: FollowInfo) { 113 - this.byDid[did] = info 114 - } 115 - 116 - removeFollow(did: string) { 117 - if (this.byDid[did]) { 118 - this.byDid[did].followRecordUri = undefined 119 - } 120 - } 121 - 122 - hydrate(did: string, profile: Profile) { 123 - this.byDid[did] = { 124 - did, 125 - followRecordUri: profile.viewer?.following, 126 - handle: profile.handle, 127 - displayName: profile.displayName, 128 - avatar: profile.avatar, 129 - } 130 - } 131 - 132 - hydrateMany(profiles: Profile[]) { 133 - for (const profile of profiles) { 134 - this.hydrate(profile.did, profile) 135 - } 136 - } 137 - }
-132
src/state/models/discovery/foafs.ts
··· 1 - import {AppBskyActorDefs} from '@atproto/api' 2 - import {makeAutoObservable, runInAction} from 'mobx' 3 - import sampleSize from 'lodash.samplesize' 4 - import {bundleAsync} from 'lib/async/bundle' 5 - import {RootStoreModel} from '../root-store' 6 - 7 - export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & { 8 - followers: AppBskyActorDefs.ProfileView[] 9 - } 10 - 11 - export type ProfileViewFollows = AppBskyActorDefs.ProfileView & { 12 - follows: AppBskyActorDefs.ProfileViewBasic[] 13 - } 14 - 15 - export class FoafsModel { 16 - isLoading = false 17 - hasData = false 18 - sources: string[] = [] 19 - foafs: Map<string, ProfileViewFollows> = new Map() // FOAF stands for Friend of a Friend 20 - popular: RefWithInfoAndFollowers[] = [] 21 - 22 - constructor(public rootStore: RootStoreModel) { 23 - makeAutoObservable(this) 24 - } 25 - 26 - get hasContent() { 27 - if (this.popular.length > 0) { 28 - return true 29 - } 30 - for (const foaf of this.foafs.values()) { 31 - if (foaf.follows.length) { 32 - return true 33 - } 34 - } 35 - return false 36 - } 37 - 38 - fetch = bundleAsync(async () => { 39 - try { 40 - this.isLoading = true 41 - 42 - // fetch some of the user's follows 43 - await this.rootStore.me.follows.syncIfNeeded() 44 - 45 - // grab 10 of the users followed by the user 46 - runInAction(() => { 47 - this.sources = sampleSize( 48 - Object.keys(this.rootStore.me.follows.byDid), 49 - 10, 50 - ) 51 - }) 52 - if (this.sources.length === 0) { 53 - return 54 - } 55 - runInAction(() => { 56 - this.foafs.clear() 57 - this.popular.length = 0 58 - }) 59 - 60 - // fetch their profiles 61 - const profiles = await this.rootStore.agent.getProfiles({ 62 - actors: this.sources, 63 - }) 64 - 65 - // fetch their follows 66 - const results = await Promise.allSettled( 67 - this.sources.map(source => 68 - this.rootStore.agent.getFollows({actor: source}), 69 - ), 70 - ) 71 - 72 - // store the follows and construct a "most followed" set 73 - const popular: RefWithInfoAndFollowers[] = [] 74 - for (let i = 0; i < results.length; i++) { 75 - const res = results[i] 76 - if (res.status === 'fulfilled') { 77 - this.rootStore.me.follows.hydrateMany(res.value.data.follows) 78 - } 79 - const profile = profiles.data.profiles[i] 80 - const source = this.sources[i] 81 - if (res.status === 'fulfilled' && profile) { 82 - // filter out inappropriate suggestions 83 - res.value.data.follows = res.value.data.follows.filter(follow => { 84 - const viewer = follow.viewer 85 - if (viewer) { 86 - if ( 87 - viewer.following || 88 - viewer.muted || 89 - viewer.mutedByList || 90 - viewer.blockedBy || 91 - viewer.blocking 92 - ) { 93 - return false 94 - } 95 - } 96 - if (follow.did === this.rootStore.me.did) { 97 - return false 98 - } 99 - return true 100 - }) 101 - 102 - runInAction(() => { 103 - this.foafs.set(source, { 104 - ...profile, 105 - follows: res.value.data.follows, 106 - }) 107 - }) 108 - for (const follow of res.value.data.follows) { 109 - let item = popular.find(p => p.did === follow.did) 110 - if (!item) { 111 - item = {...follow, followers: []} 112 - popular.push(item) 113 - } 114 - item.followers.push(profile) 115 - } 116 - } 117 - } 118 - 119 - popular.sort((a, b) => b.followers.length - a.followers.length) 120 - runInAction(() => { 121 - this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20) 122 - }) 123 - this.hasData = true 124 - } catch (e) { 125 - console.error('Failed to fetch FOAFs', e) 126 - } finally { 127 - runInAction(() => { 128 - this.isLoading = false 129 - }) 130 - } 131 - }) 132 - }
-151
src/state/models/discovery/suggested-actors.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 3 - import {RootStoreModel} from '../root-store' 4 - import {cleanError} from 'lib/strings/errors' 5 - import {bundleAsync} from 'lib/async/bundle' 6 - import {logger} from '#/logger' 7 - 8 - const PAGE_SIZE = 30 9 - 10 - export type SuggestedActor = 11 - | AppBskyActorDefs.ProfileViewBasic 12 - | AppBskyActorDefs.ProfileView 13 - 14 - export class SuggestedActorsModel { 15 - // state 16 - pageSize = PAGE_SIZE 17 - isLoading = false 18 - isRefreshing = false 19 - hasLoaded = false 20 - loadMoreCursor: string | undefined = undefined 21 - error = '' 22 - hasMore = false 23 - lastInsertedAtIndex = -1 24 - 25 - // data 26 - suggestions: SuggestedActor[] = [] 27 - 28 - constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) { 29 - if (opts?.pageSize) { 30 - this.pageSize = opts.pageSize 31 - } 32 - makeAutoObservable( 33 - this, 34 - { 35 - rootStore: false, 36 - }, 37 - {autoBind: true}, 38 - ) 39 - } 40 - 41 - get hasContent() { 42 - return this.suggestions.length > 0 43 - } 44 - 45 - get hasError() { 46 - return this.error !== '' 47 - } 48 - 49 - get isEmpty() { 50 - return this.hasLoaded && !this.hasContent 51 - } 52 - 53 - // public api 54 - // = 55 - 56 - async refresh() { 57 - return this.loadMore(true) 58 - } 59 - 60 - loadMore = bundleAsync(async (replace: boolean = false) => { 61 - if (replace) { 62 - this.hasMore = true 63 - this.loadMoreCursor = undefined 64 - } 65 - if (!this.hasMore) { 66 - return 67 - } 68 - this._xLoading(replace) 69 - try { 70 - const res = await this.rootStore.agent.app.bsky.actor.getSuggestions({ 71 - limit: 25, 72 - cursor: this.loadMoreCursor, 73 - }) 74 - let {actors, cursor} = res.data 75 - actors = actors.filter( 76 - actor => 77 - !moderateProfile(actor, this.rootStore.preferences.moderationOpts) 78 - .account.filter, 79 - ) 80 - this.rootStore.me.follows.hydrateMany(actors) 81 - 82 - runInAction(() => { 83 - if (replace) { 84 - this.suggestions = [] 85 - } 86 - this.loadMoreCursor = cursor 87 - this.hasMore = !!cursor 88 - this.suggestions = this.suggestions.concat( 89 - actors.filter(actor => { 90 - const viewer = actor.viewer 91 - if (viewer) { 92 - if ( 93 - viewer.following || 94 - viewer.muted || 95 - viewer.mutedByList || 96 - viewer.blockedBy || 97 - viewer.blocking 98 - ) { 99 - return false 100 - } 101 - } 102 - if (actor.did === this.rootStore.me.did) { 103 - return false 104 - } 105 - return true 106 - }), 107 - ) 108 - }) 109 - this._xIdle() 110 - } catch (e: any) { 111 - this._xIdle(e) 112 - } 113 - }) 114 - 115 - async insertSuggestionsByActor(actor: string, indexToInsertAt: number) { 116 - // fetch suggestions 117 - const res = 118 - await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({ 119 - actor: actor, 120 - }) 121 - const {suggestions: moreSuggestions} = res.data 122 - this.rootStore.me.follows.hydrateMany(moreSuggestions) 123 - // dedupe 124 - const toInsert = moreSuggestions.filter( 125 - s => !this.suggestions.find(s2 => s2.did === s.did), 126 - ) 127 - // insert 128 - this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert) 129 - // update index 130 - this.lastInsertedAtIndex = indexToInsertAt 131 - } 132 - 133 - // state transitions 134 - // = 135 - 136 - _xLoading(isRefreshing = false) { 137 - this.isLoading = true 138 - this.isRefreshing = isRefreshing 139 - this.error = '' 140 - } 141 - 142 - _xIdle(err?: any) { 143 - this.isLoading = false 144 - this.isRefreshing = false 145 - this.hasLoaded = true 146 - this.error = cleanError(err) 147 - if (err) { 148 - logger.error('Failed to fetch suggested actors', {error: err}) 149 - } 150 - } 151 - }
-143
src/state/models/discovery/user-autocomplete.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {AppBskyActorDefs} from '@atproto/api' 3 - import AwaitLock from 'await-lock' 4 - import {RootStoreModel} from '../root-store' 5 - import {isInvalidHandle} from 'lib/strings/handles' 6 - 7 - type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic 8 - 9 - export class UserAutocompleteModel { 10 - // state 11 - isLoading = false 12 - isActive = false 13 - prefix = '' 14 - lock = new AwaitLock() 15 - 16 - // data 17 - knownHandles: Set<string> = new Set() 18 - _suggestions: ProfileViewBasic[] = [] 19 - 20 - constructor(public rootStore: RootStoreModel) { 21 - makeAutoObservable( 22 - this, 23 - { 24 - rootStore: false, 25 - knownHandles: false, 26 - }, 27 - {autoBind: true}, 28 - ) 29 - } 30 - 31 - get follows(): ProfileViewBasic[] { 32 - return Object.values(this.rootStore.me.follows.byDid).map(item => ({ 33 - did: item.did, 34 - handle: item.handle, 35 - displayName: item.displayName, 36 - avatar: item.avatar, 37 - })) 38 - } 39 - 40 - get suggestions(): ProfileViewBasic[] { 41 - if (!this.isActive) { 42 - return [] 43 - } 44 - return this._suggestions 45 - } 46 - 47 - // public api 48 - // = 49 - 50 - async setup() { 51 - this.isLoading = true 52 - await this.rootStore.me.follows.syncIfNeeded() 53 - runInAction(() => { 54 - for (const did in this.rootStore.me.follows.byDid) { 55 - const info = this.rootStore.me.follows.byDid[did] 56 - if (!isInvalidHandle(info.handle)) { 57 - this.knownHandles.add(info.handle) 58 - } 59 - } 60 - this.isLoading = false 61 - }) 62 - } 63 - 64 - setActive(v: boolean) { 65 - this.isActive = v 66 - } 67 - 68 - async setPrefix(prefix: string) { 69 - const origPrefix = prefix.trim().toLocaleLowerCase() 70 - this.prefix = origPrefix 71 - await this.lock.acquireAsync() 72 - try { 73 - if (this.prefix) { 74 - if (this.prefix !== origPrefix) { 75 - return // another prefix was set before we got our chance 76 - } 77 - 78 - // reset to follow results 79 - this._computeSuggestions([]) 80 - 81 - // ask backend 82 - const res = await this.rootStore.agent.searchActorsTypeahead({ 83 - term: this.prefix, 84 - limit: 8, 85 - }) 86 - this._computeSuggestions(res.data.actors) 87 - 88 - // update known handles 89 - runInAction(() => { 90 - for (const u of res.data.actors) { 91 - this.knownHandles.add(u.handle) 92 - } 93 - }) 94 - } else { 95 - runInAction(() => { 96 - this._computeSuggestions([]) 97 - }) 98 - } 99 - } finally { 100 - this.lock.release() 101 - } 102 - } 103 - 104 - // internal 105 - // = 106 - 107 - _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) { 108 - if (this.prefix) { 109 - const items: ProfileViewBasic[] = [] 110 - for (const item of this.follows) { 111 - if (prefixMatch(this.prefix, item)) { 112 - items.push(item) 113 - } 114 - if (items.length >= 8) { 115 - break 116 - } 117 - } 118 - for (const item of searchRes) { 119 - if (!items.find(item2 => item2.handle === item.handle)) { 120 - items.push({ 121 - did: item.did, 122 - handle: item.handle, 123 - displayName: item.displayName, 124 - avatar: item.avatar, 125 - }) 126 - } 127 - } 128 - this._suggestions = items 129 - } else { 130 - this._suggestions = this.follows 131 - } 132 - } 133 - } 134 - 135 - function prefixMatch(prefix: string, info: ProfileViewBasic): boolean { 136 - if (info.handle.includes(prefix)) { 137 - return true 138 - } 139 - if (info.displayName?.toLocaleLowerCase().includes(prefix)) { 140 - return true 141 - } 142 - return false 143 - }
-181
src/state/models/feeds/post.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import { 3 - AppBskyFeedPost as FeedPost, 4 - AppBskyFeedDefs, 5 - RichText, 6 - moderatePost, 7 - PostModeration, 8 - } from '@atproto/api' 9 - import {RootStoreModel} from '../root-store' 10 - import {updateDataOptimistically} from 'lib/async/revertible' 11 - import {track} from 'lib/analytics/analytics' 12 - import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed' 13 - import {logger} from '#/logger' 14 - 15 - type FeedViewPost = AppBskyFeedDefs.FeedViewPost 16 - type ReasonRepost = AppBskyFeedDefs.ReasonRepost 17 - type PostView = AppBskyFeedDefs.PostView 18 - 19 - export class PostsFeedItemModel { 20 - // ui state 21 - _reactKey: string = '' 22 - 23 - // data 24 - post: PostView 25 - postRecord?: FeedPost.Record 26 - reply?: FeedViewPost['reply'] 27 - reason?: FeedViewPost['reason'] 28 - richText?: RichText 29 - 30 - constructor( 31 - public rootStore: RootStoreModel, 32 - _reactKey: string, 33 - v: FeedViewPost, 34 - ) { 35 - this._reactKey = _reactKey 36 - this.post = v.post 37 - if (FeedPost.isRecord(this.post.record)) { 38 - const valid = FeedPost.validateRecord(this.post.record) 39 - if (valid.success) { 40 - hackAddDeletedEmbed(this.post) 41 - this.postRecord = this.post.record 42 - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) 43 - } else { 44 - this.postRecord = undefined 45 - this.richText = undefined 46 - logger.warn('Received an invalid app.bsky.feed.post record', { 47 - error: valid.error, 48 - }) 49 - } 50 - } else { 51 - this.postRecord = undefined 52 - this.richText = undefined 53 - logger.warn( 54 - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', 55 - {record: this.post.record}, 56 - ) 57 - } 58 - this.reply = v.reply 59 - this.reason = v.reason 60 - makeAutoObservable(this, {rootStore: false}) 61 - } 62 - 63 - get uri() { 64 - return this.post.uri 65 - } 66 - 67 - get parentUri() { 68 - return this.postRecord?.reply?.parent.uri 69 - } 70 - 71 - get rootUri(): string { 72 - if (typeof this.postRecord?.reply?.root.uri === 'string') { 73 - return this.postRecord?.reply?.root.uri 74 - } 75 - return this.post.uri 76 - } 77 - 78 - get moderation(): PostModeration { 79 - return moderatePost(this.post, this.rootStore.preferences.moderationOpts) 80 - } 81 - 82 - copy(v: FeedViewPost) { 83 - this.post = v.post 84 - this.reply = v.reply 85 - this.reason = v.reason 86 - } 87 - 88 - copyMetrics(v: FeedViewPost) { 89 - this.post.replyCount = v.post.replyCount 90 - this.post.repostCount = v.post.repostCount 91 - this.post.likeCount = v.post.likeCount 92 - this.post.viewer = v.post.viewer 93 - } 94 - 95 - get reasonRepost(): ReasonRepost | undefined { 96 - if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { 97 - return this.reason as ReasonRepost 98 - } 99 - } 100 - 101 - async toggleLike() { 102 - this.post.viewer = this.post.viewer || {} 103 - try { 104 - if (this.post.viewer.like) { 105 - // unlike 106 - const url = this.post.viewer.like 107 - await updateDataOptimistically( 108 - this.post, 109 - () => { 110 - this.post.likeCount = (this.post.likeCount || 0) - 1 111 - this.post.viewer!.like = undefined 112 - }, 113 - () => this.rootStore.agent.deleteLike(url), 114 - ) 115 - track('Post:Unlike') 116 - } else { 117 - // like 118 - await updateDataOptimistically( 119 - this.post, 120 - () => { 121 - this.post.likeCount = (this.post.likeCount || 0) + 1 122 - this.post.viewer!.like = 'pending' 123 - }, 124 - () => this.rootStore.agent.like(this.post.uri, this.post.cid), 125 - res => { 126 - this.post.viewer!.like = res.uri 127 - }, 128 - ) 129 - track('Post:Like') 130 - } 131 - } catch (error) { 132 - logger.error('Failed to toggle like', {error}) 133 - } 134 - } 135 - 136 - async toggleRepost() { 137 - this.post.viewer = this.post.viewer || {} 138 - try { 139 - if (this.post.viewer?.repost) { 140 - // unrepost 141 - const url = this.post.viewer.repost 142 - await updateDataOptimistically( 143 - this.post, 144 - () => { 145 - this.post.repostCount = (this.post.repostCount || 0) - 1 146 - this.post.viewer!.repost = undefined 147 - }, 148 - () => this.rootStore.agent.deleteRepost(url), 149 - ) 150 - track('Post:Unrepost') 151 - } else { 152 - // repost 153 - await updateDataOptimistically( 154 - this.post, 155 - () => { 156 - this.post.repostCount = (this.post.repostCount || 0) + 1 157 - this.post.viewer!.repost = 'pending' 158 - }, 159 - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), 160 - res => { 161 - this.post.viewer!.repost = res.uri 162 - }, 163 - ) 164 - track('Post:Repost') 165 - } 166 - } catch (error) { 167 - logger.error('Failed to toggle repost', {error}) 168 - } 169 - } 170 - 171 - async delete() { 172 - try { 173 - await this.rootStore.agent.deletePost(this.post.uri) 174 - this.rootStore.emitPostDeleted(this.post.uri) 175 - } catch (error) { 176 - logger.error('Failed to delete post', {error}) 177 - } finally { 178 - track('Post:Delete') 179 - } 180 - } 181 - }
-4
src/state/models/me.ts
··· 4 4 ComAtprotoServerListAppPasswords, 5 5 } from '@atproto/api' 6 6 import {RootStoreModel} from './root-store' 7 - import {MyFollowsCache} from './cache/my-follows' 8 7 import {isObj, hasProp} from 'lib/type-guards' 9 8 import {logger} from '#/logger' 10 9 ··· 18 17 avatar: string = '' 19 18 followsCount: number | undefined 20 19 followersCount: number | undefined 21 - follows: MyFollowsCache 22 20 invites: ComAtprotoServerDefs.InviteCode[] = [] 23 21 appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] 24 22 lastProfileStateUpdate = Date.now() ··· 33 31 {rootStore: false, serialize: false, hydrate: false}, 34 32 {autoBind: true}, 35 33 ) 36 - this.follows = new MyFollowsCache(this.rootStore) 37 34 } 38 35 39 36 clear() { 40 - this.follows.clear() 41 37 this.rootStore.profiles.cache.clear() 42 38 this.rootStore.posts.cache.clear() 43 39 this.did = ''
-6
src/state/models/root-store.ts
··· 15 15 import {PostsCache} from './cache/posts' 16 16 import {LinkMetasCache} from './cache/link-metas' 17 17 import {MeModel} from './me' 18 - import {PreferencesModel} from './ui/preferences' 19 18 import {resetToTab} from '../../Navigation' 20 19 import {ImageSizesCache} from './cache/image-sizes' 21 20 import {reset as resetNavigation} from '../../Navigation' ··· 39 38 appInfo?: AppInfo 40 39 session = new SessionModel(this) 41 40 shell = new ShellUiModel(this) 42 - preferences = new PreferencesModel(this) 43 41 me = new MeModel(this) 44 42 handleResolutions = new HandleResolutionsCache() 45 43 profiles = new ProfilesCache(this) ··· 64 62 return { 65 63 appInfo: this.appInfo, 66 64 me: this.me.serialize(), 67 - preferences: this.preferences.serialize(), 68 65 } 69 66 } 70 67 ··· 78 75 } 79 76 if (hasProp(v, 'me')) { 80 77 this.me.hydrate(v.me) 81 - } 82 - if (hasProp(v, 'preferences')) { 83 - this.preferences.hydrate(v.preferences) 84 78 } 85 79 } 86 80 }
-129
src/state/models/ui/preferences.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import { 3 - LabelPreference as APILabelPreference, 4 - BskyThreadViewPreference, 5 - } from '@atproto/api' 6 - import {isObj, hasProp} from 'lib/type-guards' 7 - import {RootStoreModel} from '../root-store' 8 - import {ModerationOpts} from '@atproto/api' 9 - 10 - // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf 11 - export type LabelPreference = APILabelPreference | 'show' 12 - export type ThreadViewPreference = BskyThreadViewPreference & { 13 - lab_treeViewEnabled?: boolean | undefined 14 - } 15 - 16 - export class LabelPreferencesModel { 17 - nsfw: LabelPreference = 'hide' 18 - nudity: LabelPreference = 'warn' 19 - suggestive: LabelPreference = 'warn' 20 - gore: LabelPreference = 'warn' 21 - hate: LabelPreference = 'hide' 22 - spam: LabelPreference = 'hide' 23 - impersonation: LabelPreference = 'warn' 24 - 25 - constructor() { 26 - makeAutoObservable(this, {}, {autoBind: true}) 27 - } 28 - } 29 - 30 - export class PreferencesModel { 31 - contentLabels = new LabelPreferencesModel() 32 - savedFeeds: string[] = [] 33 - pinnedFeeds: string[] = [] 34 - 35 - constructor(public rootStore: RootStoreModel) { 36 - makeAutoObservable(this, {}, {autoBind: true}) 37 - } 38 - 39 - serialize() { 40 - return { 41 - contentLabels: this.contentLabels, 42 - savedFeeds: this.savedFeeds, 43 - pinnedFeeds: this.pinnedFeeds, 44 - } 45 - } 46 - 47 - /** 48 - * The function hydrates an object with properties related to content languages, labels, saved feeds, 49 - * and pinned feeds that it gets from the parameter `v` (probably local storage) 50 - * @param {unknown} v - the data object to hydrate from 51 - */ 52 - hydrate(v: unknown) { 53 - if (isObj(v)) { 54 - // check if content labels in preferences exist, then hydrate 55 - if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { 56 - Object.assign(this.contentLabels, v.contentLabels) 57 - } 58 - // check if saved feeds in preferences, then hydrate 59 - if ( 60 - hasProp(v, 'savedFeeds') && 61 - Array.isArray(v.savedFeeds) && 62 - typeof v.savedFeeds.every(item => typeof item === 'string') 63 - ) { 64 - this.savedFeeds = v.savedFeeds 65 - } 66 - // check if pinned feeds in preferences exist, then hydrate 67 - if ( 68 - hasProp(v, 'pinnedFeeds') && 69 - Array.isArray(v.pinnedFeeds) && 70 - typeof v.pinnedFeeds.every(item => typeof item === 'string') 71 - ) { 72 - this.pinnedFeeds = v.pinnedFeeds 73 - } 74 - } 75 - } 76 - 77 - // moderation 78 - // = 79 - 80 - /** 81 - * @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead 82 - */ 83 - get moderationOpts(): ModerationOpts { 84 - return { 85 - userDid: this.rootStore.session.currentSession?.did || '', 86 - adultContentEnabled: false, 87 - labels: { 88 - // TEMP translate old settings until this UI can be migrated -prf 89 - porn: tempfixLabelPref(this.contentLabels.nsfw), 90 - sexual: tempfixLabelPref(this.contentLabels.suggestive), 91 - nudity: tempfixLabelPref(this.contentLabels.nudity), 92 - nsfl: tempfixLabelPref(this.contentLabels.gore), 93 - corpse: tempfixLabelPref(this.contentLabels.gore), 94 - gore: tempfixLabelPref(this.contentLabels.gore), 95 - torture: tempfixLabelPref(this.contentLabels.gore), 96 - 'self-harm': tempfixLabelPref(this.contentLabels.gore), 97 - 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), 98 - 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), 99 - 'intolerant-sexual-orientation': tempfixLabelPref( 100 - this.contentLabels.hate, 101 - ), 102 - 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), 103 - intolerant: tempfixLabelPref(this.contentLabels.hate), 104 - 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), 105 - spam: tempfixLabelPref(this.contentLabels.spam), 106 - impersonation: tempfixLabelPref(this.contentLabels.impersonation), 107 - scam: 'warn', 108 - }, 109 - labelers: [ 110 - { 111 - labeler: { 112 - did: '', 113 - displayName: 'Bluesky Social', 114 - }, 115 - labels: {}, 116 - }, 117 - ], 118 - } 119 - } 120 - } 121 - 122 - // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf 123 - // TODO do we need this? 124 - function tempfixLabelPref(pref: LabelPreference): APILabelPreference { 125 - if (pref === 'show') { 126 - return 'ignore' 127 - } 128 - return pref 129 - }
+3 -4
src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
··· 1 1 import React from 'react' 2 2 import {View, StyleSheet, ActivityIndicator} from 'react-native' 3 - import {ProfileModeration} from '@atproto/api' 3 + import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' 4 4 import {Button} from '#/view/com/util/forms/Button' 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 - import {SuggestedActor} from 'state/models/discovery/suggested-actors' 7 6 import {sanitizeDisplayName} from 'lib/strings/display-names' 8 7 import {sanitizeHandle} from 'lib/strings/handles' 9 8 import {s} from 'lib/styles' ··· 21 20 import {logger} from '#/logger' 22 21 23 22 type Props = { 24 - profile: SuggestedActor 23 + profile: AppBskyActorDefs.ProfileViewBasic 25 24 dataUpdatedAt: number 26 25 moderation: ProfileModeration 27 26 onFollowStateChange: (props: { ··· 67 66 onFollowStateChange, 68 67 moderation, 69 68 }: { 70 - profile: Shadow<SuggestedActor> 69 + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 71 70 moderation: ProfileModeration 72 71 onFollowStateChange: (props: { 73 72 did: string
+13 -3
src/view/com/posts/Feed.tsx
··· 24 24 FeedParams, 25 25 usePostFeedQuery, 26 26 } from '#/state/queries/post-feed' 27 + import {useModerationOpts} from '#/state/queries/preferences' 27 28 28 29 const LOADING_ITEM = {_reactKey: '__loading__'} 29 30 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} ··· 71 72 const [isPTRing, setIsPTRing] = React.useState(false) 72 73 const checkForNewRef = React.useRef<(() => void) | null>(null) 73 74 75 + const moderationOpts = useModerationOpts() 74 76 const opts = React.useMemo(() => ({enabled}), [enabled]) 75 77 const { 76 78 data, ··· 115 117 116 118 const feedItems = React.useMemo(() => { 117 119 let arr: any[] = [] 118 - if (isFetched) { 120 + if (isFetched && moderationOpts) { 119 121 if (isError && isEmpty) { 120 122 arr = arr.concat([ERROR_ITEM]) 121 123 } ··· 133 135 arr.push(LOADING_ITEM) 134 136 } 135 137 return arr 136 - }, [isFetched, isError, isEmpty, data]) 138 + }, [isFetched, isError, isEmpty, data, moderationOpts]) 137 139 138 140 // events 139 141 // = ··· 195 197 } else if (item === LOADING_ITEM) { 196 198 return <PostFeedLoadingPlaceholder /> 197 199 } 198 - return <FeedSlice slice={item} dataUpdatedAt={dataUpdatedAt} /> 200 + return ( 201 + <FeedSlice 202 + slice={item} 203 + dataUpdatedAt={dataUpdatedAt} 204 + // we check for this before creating the feedItems array 205 + moderationOpts={moderationOpts!} 206 + /> 207 + ) 199 208 }, 200 209 [ 201 210 feed, ··· 204 213 onPressTryAgain, 205 214 onPressRetryLoadMore, 206 215 renderEmptyState, 216 + moderationOpts, 207 217 ], 208 218 ) 209 219
+5 -7
src/view/com/posts/FeedSlice.tsx
··· 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import {FeedPostSlice} from '#/state/queries/post-feed' 5 - import {AtUri, moderatePost} from '@atproto/api' 5 + import {AtUri, moderatePost, ModerationOpts} from '@atproto/api' 6 6 import {Link} from '../util/Link' 7 7 import {Text} from '../util/text/Text' 8 8 import Svg, {Circle, Line} from 'react-native-svg' 9 9 import {FeedItem} from './FeedItem' 10 10 import {usePalette} from 'lib/hooks/usePalette' 11 11 import {makeProfileLink} from 'lib/routes/links' 12 - import {useStores} from '#/state' 13 12 14 13 export const FeedSlice = observer(function FeedSliceImpl({ 15 14 slice, 16 15 dataUpdatedAt, 17 16 ignoreFilterFor, 17 + moderationOpts, 18 18 }: { 19 19 slice: FeedPostSlice 20 20 dataUpdatedAt: number 21 21 ignoreFilterFor?: string 22 + moderationOpts: ModerationOpts 22 23 }) { 23 - const store = useStores() 24 24 const moderations = React.useMemo(() => { 25 - return slice.items.map(item => 26 - moderatePost(item.post, store.preferences.moderationOpts), 27 - ) 28 - }, [slice, store.preferences.moderationOpts]) 25 + return slice.items.map(item => moderatePost(item.post, moderationOpts)) 26 + }, [slice, moderationOpts]) 29 27 30 28 // apply moderation filter 31 29 for (let i = 0; i < slice.items.length; i++) {
+16 -10
src/view/com/profile/ProfileCard.tsx
··· 11 11 import {UserAvatar} from '../util/UserAvatar' 12 12 import {s} from 'lib/styles' 13 13 import {usePalette} from 'lib/hooks/usePalette' 14 - import {useStores} from 'state/index' 15 14 import {FollowButton} from './FollowButton' 16 15 import {sanitizeDisplayName} from 'lib/strings/display-names' 17 16 import {sanitizeHandle} from 'lib/strings/handles' ··· 158 157 }: { 159 158 followers?: AppBskyActorDefs.ProfileView[] | undefined 160 159 }) { 161 - const store = useStores() 162 160 const pal = usePalette('default') 163 - if (!followers?.length) { 161 + const moderationOpts = useModerationOpts() 162 + 163 + const followersWithMods = React.useMemo(() => { 164 + if (!followers || !moderationOpts) { 165 + return [] 166 + } 167 + 168 + return followers 169 + .map(f => ({ 170 + f, 171 + mod: moderateProfile(f, moderationOpts), 172 + })) 173 + .filter(({mod}) => !mod.account.filter) 174 + }, [followers, moderationOpts]) 175 + 176 + if (!followersWithMods?.length) { 164 177 return null 165 178 } 166 - 167 - const followersWithMods = followers 168 - .map(f => ({ 169 - f, 170 - mod: moderateProfile(f, store.preferences.moderationOpts), 171 - })) 172 - .filter(({mod}) => !mod.account.filter) 173 179 174 180 return ( 175 181 <View style={styles.followedBy}>