Labeling & moderation updates [DRAFT] (#1057)
* First pass moving to the new labeling sdk (it compiles) * Correct behaviors around interpreting label moderation * Improve moderation state rendering * Improve hiders and alerts * Improve handling of mutes * Improve profile warnings * Add profile blurring to profile header * Add blocks to test cases * Render labels on profile cards, do not filter * Filter profiles from suggestions using moderation * Apply profile blurring to ProfileCard * Handle blocked and deleted quote posts * Temporarily translate content filtering settings to new labels * Fix types * Tune ContentHider & PostHider click targets * Put a warning on profilecard label pills * Fix screenhider learnmore link on mobile * Enforce no-override on user avatar * Dont enumerate profile blur-media labels in alerts * Fixes to muted posts (esp quotes of muted users) * Fixes to account/profile warnings * Bump @atproto/api@0.5.0 * Bump @atproto/api@0.5.1 * Fix tests * 1.43 * Remove log * Bump @atproto/api@0.5.2
This commit is contained in:
parent
3ae5a6b631
commit
b154d3ea21
43 changed files with 1193 additions and 717 deletions
|
@ -3,9 +3,9 @@ import {
|
|||
AppBskyFeedPost as FeedPost,
|
||||
AppBskyFeedDefs,
|
||||
RichText,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {PostsFeedItemModel} from '../feeds/post'
|
||||
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
|
@ -67,10 +67,6 @@ export class PostThreadItemModel {
|
|||
return this.data.isThreadMuted
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
return this.data.labelInfo
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return this.data.moderation
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
|||
import {
|
||||
AppBskyFeedGetPostThread as GetPostThread,
|
||||
AppBskyFeedDefs,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
|
@ -231,7 +232,6 @@ export class PostThreadModel {
|
|||
return
|
||||
}
|
||||
pruneReplies(res.data.thread)
|
||||
sortThread(res.data.thread)
|
||||
const thread = new PostThreadItemModel(
|
||||
this.rootStore,
|
||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
|
@ -241,6 +241,7 @@ export class PostThreadModel {
|
|||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
thread.uri,
|
||||
)
|
||||
sortThread(thread)
|
||||
this.thread = thread
|
||||
}
|
||||
}
|
||||
|
@ -262,24 +263,28 @@ function pruneReplies(post: MaybePost) {
|
|||
}
|
||||
}
|
||||
|
||||
function sortThread(post: MaybePost) {
|
||||
if (post.notFound) {
|
||||
type MaybeThreadItem =
|
||||
| PostThreadItemModel
|
||||
| AppBskyFeedDefs.NotFoundPost
|
||||
| AppBskyFeedDefs.BlockedPost
|
||||
function sortThread(item: MaybeThreadItem) {
|
||||
if ('notFound' in item) {
|
||||
return
|
||||
}
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (post.replies) {
|
||||
post.replies.sort((a: MaybePost, b: MaybePost) => {
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (a.notFound) {
|
||||
item = item as PostThreadItemModel
|
||||
if (item.replies) {
|
||||
item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
|
||||
if ('notFound' in a && a.notFound) {
|
||||
return 1
|
||||
}
|
||||
if (b.notFound) {
|
||||
if ('notFound' in b && b.notFound) {
|
||||
return -1
|
||||
}
|
||||
a = a as AppBskyFeedDefs.ThreadViewPost
|
||||
b = b as AppBskyFeedDefs.ThreadViewPost
|
||||
const aIsByOp = a.post.author.did === post.post.author.did
|
||||
const bIsByOp = b.post.author.did === post.post.author.did
|
||||
item = item as PostThreadItemModel
|
||||
a = a as PostThreadItemModel
|
||||
b = b as PostThreadItemModel
|
||||
const aIsByOp = a.post.author.did === item.post.author.did
|
||||
const bIsByOp = b.post.author.did === item.post.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsByOp) {
|
||||
|
@ -287,8 +292,31 @@ function sortThread(post: MaybePost) {
|
|||
} else if (bIsByOp) {
|
||||
return 1 // op's own reply
|
||||
}
|
||||
// put moderated content down at the bottom
|
||||
if (modScore(a.moderation) !== modScore(b.moderation)) {
|
||||
return modScore(a.moderation) - modScore(b.moderation)
|
||||
}
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
||||
})
|
||||
post.replies.forEach(reply => sortThread(reply))
|
||||
item.replies.forEach(reply => sortThread(reply))
|
||||
}
|
||||
}
|
||||
|
||||
function modScore(mod: PostModeration): number {
|
||||
if (mod.content.blur && mod.content.noOverride) {
|
||||
return 5
|
||||
}
|
||||
if (mod.content.blur) {
|
||||
return 4
|
||||
}
|
||||
if (mod.content.alert) {
|
||||
return 3
|
||||
}
|
||||
if (mod.embed.blur && mod.embed.noOverride) {
|
||||
return 2
|
||||
}
|
||||
if (mod.embed.blur) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
|
@ -6,18 +6,14 @@ import {
|
|||
AppBskyActorGetProfile as GetProfile,
|
||||
AppBskyActorProfile,
|
||||
RichText,
|
||||
moderateProfile,
|
||||
ProfileModeration,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
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'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
|
||||
export class ProfileViewerModel {
|
||||
|
@ -26,7 +22,8 @@ export class ProfileViewerModel {
|
|||
following?: string
|
||||
followedBy?: string
|
||||
blockedBy?: boolean
|
||||
blocking?: string
|
||||
blocking?: string;
|
||||
[key: string]: unknown
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
|
@ -53,7 +50,8 @@ export class ProfileModel {
|
|||
followsCount: number = 0
|
||||
postsCount: number = 0
|
||||
labels?: ComAtprotoLabelDefs.Label[] = undefined
|
||||
viewer = new ProfileViewerModel()
|
||||
viewer = new ProfileViewerModel();
|
||||
[key: string]: unknown
|
||||
|
||||
// added data
|
||||
descriptionRichText?: RichText = new RichText({text: ''})
|
||||
|
@ -85,18 +83,8 @@ 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,
|
||||
isBlocking: !!this.viewer?.blocking || false,
|
||||
isBlockedBy: !!this.viewer?.blockedBy || false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): ProfileModeration {
|
||||
return getProfileModeration(this.rootStore, this.labelInfo)
|
||||
return moderateProfile(this, this.rootStore.preferences.moderationOpts)
|
||||
}
|
||||
|
||||
// public api
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetFollows as GetFollows,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import sampleSize from 'lodash.samplesize'
|
||||
|
@ -52,6 +53,13 @@ export class FoafsModel {
|
|||
cursor,
|
||||
limit: 100,
|
||||
})
|
||||
res.data.follows = res.data.follows.filter(
|
||||
profile =>
|
||||
!moderateProfile(
|
||||
profile,
|
||||
this.rootStore.preferences.moderationOpts,
|
||||
).account.filter,
|
||||
)
|
||||
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
|
||||
if (!res.data.cursor) {
|
||||
break
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
@ -69,7 +69,12 @@ export class SuggestedActorsModel {
|
|||
limit: 25,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
const {actors, cursor} = res.data
|
||||
let {actors, cursor} = res.data
|
||||
actors = actors.filter(
|
||||
actor =>
|
||||
!moderateProfile(actor, this.rootStore.preferences.moderationOpts)
|
||||
.account.filter,
|
||||
)
|
||||
this.rootStore.me.follows.hydrateProfiles(actors)
|
||||
|
||||
runInAction(() => {
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
AppBskyFeedLike,
|
||||
AppBskyGraphFollow,
|
||||
ComAtprotoLabelDefs,
|
||||
moderatePost,
|
||||
moderateProfile,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import chunk from 'lodash.chunk'
|
||||
|
@ -15,16 +17,6 @@ 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
|
||||
|
@ -100,27 +92,19 @@ 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,
|
||||
mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
|
||||
isBlocking:
|
||||
!!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
|
||||
isBlockedBy:
|
||||
!!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false,
|
||||
get shouldFilter(): boolean {
|
||||
if (this.additionalPost?.thread) {
|
||||
const postMod = moderatePost(
|
||||
this.additionalPost.thread.data.post,
|
||||
this.rootStore.preferences.moderationOpts,
|
||||
)
|
||||
return postMod.content.filter || false
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
const profileMod = moderateProfile(
|
||||
this.author,
|
||||
this.rootStore.preferences.moderationOpts,
|
||||
)
|
||||
return profileMod.account.filter || false
|
||||
}
|
||||
|
||||
get numUnreadInGroup(): number {
|
||||
|
@ -565,8 +549,7 @@ export class NotificationsFeedModel {
|
|||
): NotificationsFeedItemModel[] {
|
||||
return items
|
||||
.filter(item => {
|
||||
const hideByLabel =
|
||||
item.moderation.list.behavior === ModerationBehaviorCode.Hide
|
||||
const hideByLabel = item.shouldFilter
|
||||
let mutedThread = !!(
|
||||
item.reasonSubjectRootUri &&
|
||||
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
|
||||
|
|
|
@ -3,21 +3,13 @@ import {
|
|||
AppBskyFeedPost as FeedPost,
|
||||
AppBskyFeedDefs,
|
||||
RichText,
|
||||
moderatePost,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {
|
||||
getEmbedLabels,
|
||||
getEmbedMuted,
|
||||
getEmbedMutedByList,
|
||||
getEmbedBlocking,
|
||||
getEmbedBlockedBy,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
getPostModeration,
|
||||
} from 'lib/labeling/helpers'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
|
@ -44,6 +36,7 @@ export class PostsFeedItemModel {
|
|||
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 {
|
||||
|
@ -86,33 +79,8 @@ 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 ||
|
||||
getEmbedMuted(this.post.embed) ||
|
||||
false,
|
||||
mutedByList:
|
||||
this.post.author.viewer?.mutedByList ||
|
||||
getEmbedMutedByList(this.post.embed),
|
||||
isBlocking:
|
||||
!!this.post.author.viewer?.blocking ||
|
||||
getEmbedBlocking(this.post.embed) ||
|
||||
false,
|
||||
isBlockedBy:
|
||||
!!this.post.author.viewer?.blockedBy ||
|
||||
getEmbedBlockedBy(this.post.embed) ||
|
||||
false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
return moderatePost(this.post, this.rootStore.preferences.moderationOpts)
|
||||
}
|
||||
|
||||
copy(v: FeedViewPost) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
import {mergePostModerations} from 'lib/labeling/helpers'
|
||||
import {PostsFeedItemModel} from './post'
|
||||
|
||||
let _idCounter = 0
|
||||
|
@ -55,7 +54,20 @@ export class PostsFeedSliceModel {
|
|||
}
|
||||
|
||||
get moderation() {
|
||||
return mergePostModerations(this.items.map(item => item.moderation))
|
||||
// prefer the most stringent item
|
||||
const topItem = this.items.find(item => item.moderation.content.filter)
|
||||
if (topItem) {
|
||||
return topItem.moderation
|
||||
}
|
||||
// otherwise just use the first one
|
||||
return this.items[0].moderation
|
||||
}
|
||||
|
||||
shouldFilter(ignoreFilterForDid: string | undefined): boolean {
|
||||
const mods = this.items
|
||||
.filter(item => item.post.author.did !== ignoreFilterForDid)
|
||||
.map(item => item.moderation)
|
||||
return !!mods.find(mod => mod.content.filter)
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {LabelPreference as APILabelPreference} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
|
||||
import {
|
||||
ComAtprotoLabelDefs,
|
||||
AppBskyActorDefs,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {LabelValGroup} from 'lib/labeling/types'
|
||||
import {getLabelValueGroup} from 'lib/labeling/helpers'
|
||||
import {
|
||||
|
@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants'
|
|||
import {isIOS, deviceLocales} from 'platform/detection'
|
||||
import {LANGUAGES} from '../../../locale/languages'
|
||||
|
||||
export type LabelPreference = 'show' | 'warn' | 'hide'
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
export type LabelPreference = APILabelPreference | 'show'
|
||||
const LABEL_GROUPS = [
|
||||
'nsfw',
|
||||
'nudity',
|
||||
|
@ -408,6 +414,43 @@ export class PreferencesModel {
|
|||
return res
|
||||
}
|
||||
|
||||
get moderationOpts(): ModerationOpts {
|
||||
return {
|
||||
userDid: this.rootStore.session.currentSession?.did || '',
|
||||
adultContentEnabled: this.adultContentEnabled,
|
||||
labelerSettings: [
|
||||
{
|
||||
labeler: {
|
||||
did: '',
|
||||
displayName: 'Bluesky Social',
|
||||
},
|
||||
settings: {
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async setSavedFeeds(saved: string[], pinned: string[]) {
|
||||
const oldSaved = this.savedFeeds
|
||||
const oldPinned = this.pinnedFeeds
|
||||
|
@ -485,3 +528,11 @@ export class PreferencesModel {
|
|||
this.requireAltTextEnabled = !this.requireAltTextEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
|
||||
function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
|
||||
if (pref === 'show') {
|
||||
return 'ignore'
|
||||
}
|
||||
return pref
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {ProfileModel} from '../content/profile'
|
||||
|
@ -42,6 +42,12 @@ export interface ServerInputModal {
|
|||
onSelect: (url: string) => void
|
||||
}
|
||||
|
||||
export interface ModerationDetailsModal {
|
||||
name: 'moderation-details'
|
||||
context: 'account' | 'content'
|
||||
moderation: ModerationUI
|
||||
}
|
||||
|
||||
export interface ReportPostModal {
|
||||
name: 'report-post'
|
||||
postUri: string
|
||||
|
@ -146,6 +152,7 @@ export type Modal =
|
|||
| PreferencesHomeFeed
|
||||
|
||||
// Moderation
|
||||
| ModerationDetailsModal
|
||||
| ReportAccountModal
|
||||
| ReportPostModal
|
||||
| CreateOrEditMuteListModal
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue