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:
parent
51be8474db
commit
1d50ddb378
40 changed files with 1195 additions and 763 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
// =
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue