Refactor moderation to apply to accounts, profiles, and posts correctly (#548)

* Add ScreenHider component

* Add blur attribute to UserAvatar and UserBanner

* Remove dead suggested posts component and model

* Bump @atproto/api@0.2.10

* Rework moderation tooling to give a more precise DSL

* Add label mocks

* Apply finer grained moderation controls

* Refactor ProfileCard to just take the profile object

* Apply moderation to user listings and banner

* Apply moderation to notifications

* Fix lint

* Tune avatar & banner blur settings per platform

* 1.24
This commit is contained in:
Paul Frazee 2023-04-27 12:38:23 -05:00 committed by GitHub
parent 51be8474db
commit 1d50ddb378
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1195 additions and 763 deletions

View file

@ -10,6 +10,13 @@ import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {updateDataOptimistically} from 'lib/async/revertible'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
filterAccountLabels,
filterProfileLabels,
getPostModeration,
} from 'lib/labeling/helpers'
export class PostThreadItemModel {
// ui state
@ -46,6 +53,21 @@ export class PostThreadItemModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
get labelInfo(): PostLabelInfo {
return {
postLabels: (this.post.labels || []).concat(
getEmbedLabels(this.post.embed),
),
accountLabels: filterAccountLabels(this.post.author.labels),
profileLabels: filterProfileLabels(this.post.author.labels),
isMuted: this.post.author.viewer?.muted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
constructor(
public rootStore: RootStoreModel,
v: AppBskyFeedDefs.ThreadViewPost,

View file

@ -1,122 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyFeedPost as Post} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
type RemoveIndex<T> = {
[P in keyof T as string extends P
? never
: number extends P
? never
: P]: T[P]
}
export class PostModel implements RemoveIndex<Post.Record> {
// state
isLoading = false
hasLoaded = false
error = ''
uri: string = ''
// data
text: string = ''
entities?: Post.Entity[]
reply?: Post.ReplyRef
createdAt: string = ''
constructor(public rootStore: RootStoreModel, uri: string) {
makeAutoObservable(
this,
{
rootStore: false,
uri: false,
},
{autoBind: true},
)
this.uri = uri
}
get hasContent() {
return this.createdAt !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get rootUri(): string {
if (this.reply?.root.uri) {
return this.reply.root.uri
}
return this.uri
}
get isThreadMuted() {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
// public api
// =
async setup() {
await this._load()
}
async toggleThreadMute() {
if (this.isThreadMuted) {
this.rootStore.mutedThreads.uris.delete(this.rootUri)
} else {
this.rootStore.mutedThreads.uris.add(this.rootUri)
}
}
// state transitions
// =
_xLoading() {
this.isLoading = true
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch post', err)
}
}
// loader functions
// =
async _load() {
this._xLoading()
try {
const urip = new AtUri(this.uri)
const res = await this.rootStore.agent.getPost({
repo: urip.host,
rkey: urip.rkey,
})
// TODO
// if (!res.valid) {
// throw new Error(res.error)
// }
this._replaceAll(res.value)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
_replaceAll(res: Post.Record) {
this.text = res.text
this.entities = res.entities
this.reply = res.reply
this.createdAt = res.createdAt
}
}

View file

@ -10,6 +10,12 @@ import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {FollowState} from '../cache/my-follows'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types'
import {
getProfileModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
@ -75,6 +81,18 @@ export class ProfileModel {
return this.hasLoaded && !this.hasContent
}
get labelInfo(): ProfileLabelInfo {
return {
accountLabels: filterAccountLabels(this.labels),
profileLabels: filterProfileLabels(this.labels),
isMuted: this.viewer?.muted || false,
}
}
get moderation(): ProfileModeration {
return getProfileModeration(this.rootStore, this.labelInfo)
}
// public api
// =

View file

@ -1,88 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {PostsFeedItemModel} from '../feeds/posts'
import {cleanError} from 'lib/strings/errors'
import {TEAM_HANDLES} from 'lib/constants'
import {
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
export class SuggestedPostsModel {
// state
isLoading = false
hasLoaded = false
error = ''
// data
posts: PostsFeedItemModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.posts.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
this._xLoading()
try {
const responses = await getMultipleAuthorsPosts(
this.rootStore,
TEAM_HANDLES(String(this.rootStore.agent.service)),
undefined,
30,
)
runInAction(() => {
const finalPosts = mergePosts(responses, {repostsOnly: true})
// hydrate into models
this.posts = finalPosts.map((post, i) => {
// strip the reasons to hide that these are reposts
delete post.reason
return new PostsFeedItemModel(this.rootStore, `post-${i}`, post)
})
})
this._xIdle()
} catch (e: any) {
this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
e,
})
this._xIdle() // dont bubble to the user
}
}
// state transitions
// =
_xLoading() {
this.isLoading = true
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch suggested posts', err)
}
}
}

View file

@ -15,6 +15,16 @@ import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
import {PostThreadModel} from '../content/post-thread'
import {cleanError} from 'lib/strings/errors'
import {
PostLabelInfo,
PostModeration,
ModerationBehaviorCode,
} from 'lib/labeling/types'
import {
getPostModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30
@ -90,6 +100,24 @@ export class NotificationsFeedItemModel {
}
}
get labelInfo(): PostLabelInfo {
const addedInfo = this.additionalPost?.thread?.labelInfo
return {
postLabels: (this.labels || []).concat(addedInfo?.postLabels || []),
accountLabels: filterAccountLabels(this.author.labels).concat(
addedInfo?.accountLabels || [],
),
profileLabels: filterProfileLabels(this.author.labels).concat(
addedInfo?.profileLabels || [],
),
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
get numUnreadInGroup(): number {
if (this.additional?.length) {
return (
@ -520,16 +548,22 @@ export class NotificationsFeedModel {
_filterNotifications(
items: NotificationsFeedItemModel[],
): NotificationsFeedItemModel[] {
return items.filter(item => {
const hideByLabel =
this.rootStore.preferences.getLabelPreference(item.labels).pref ===
'hide'
let mutedThread = !!(
item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
)
return !hideByLabel && !mutedThread
})
return items
.filter(item => {
const hideByLabel =
item.moderation.list.behavior === ModerationBehaviorCode.Hide
let mutedThread = !!(
item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
)
return !hideByLabel && !mutedThread
})
.map(item => {
if (item.additional?.length) {
item.additional = this._filterNotifications(item.additional)
}
return item
})
}
async _fetchItemModels(

View file

@ -20,6 +20,13 @@ import {
} from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
import {updateDataOptimistically} from 'lib/async/revertible'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
getPostModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@ -83,6 +90,21 @@ export class PostsFeedItemModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
get labelInfo(): PostLabelInfo {
return {
postLabels: (this.post.labels || []).concat(
getEmbedLabels(this.post.embed),
),
accountLabels: filterAccountLabels(this.post.author.labels),
profileLabels: filterProfileLabels(this.post.author.labels),
isMuted: this.post.author.viewer?.muted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
copy(v: FeedViewPost) {
this.post = v.post
this.reply = v.reply