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

@ -1,23 +1,20 @@
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export interface LabelValGroup {
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
title: string
subtitle?: string
warning?: string
values: string[]
}
import {LabelValGroup} from './types'
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
id: 'illegal',
title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
imagesOnly: false, // not applicable
}
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
id: 'unknown',
title: 'Unknown Label',
warning: 'Content Warning',
values: [],
imagesOnly: false,
}
export const CONFIGURABLE_LABEL_GROUPS: Record<
@ -30,6 +27,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'i.e. Pornography',
warning: 'Sexually Explicit',
values: ['porn'],
imagesOnly: false, // apply to whole thing
},
nudity: {
id: 'nudity',
@ -37,6 +35,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Including non-sexual and artistic',
warning: 'Nudity',
values: ['nudity'],
imagesOnly: true,
},
suggestive: {
id: 'suggestive',
@ -44,6 +43,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive',
values: ['sexual'],
imagesOnly: true,
},
gore: {
id: 'gore',
@ -51,12 +51,14 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Gore, self-harm, torture',
warning: 'Violence',
values: ['gore', 'self-harm', 'torture'],
imagesOnly: true,
},
hate: {
id: 'hate',
title: 'Political Hate-Groups',
warning: 'Hate',
values: ['icon-kkk', 'icon-nazi'],
imagesOnly: false,
},
spam: {
id: 'spam',
@ -64,6 +66,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Excessive low-quality posts',
warning: 'Spam',
values: ['spam'],
imagesOnly: false,
},
impersonation: {
id: 'impersonation',
@ -71,5 +74,6 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation',
values: ['impersonation'],
imagesOnly: false,
},
}

View file

@ -1,9 +1,33 @@
import {
LabelValGroup,
AppBskyActorDefs,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
import {
CONFIGURABLE_LABEL_GROUPS,
ILLEGAL_LABEL_GROUP,
UNKNOWN_LABEL_GROUP,
} from './const'
import {
Label,
LabelValGroup,
ModerationBehaviorCode,
PostModeration,
ProfileModeration,
PostLabelInfo,
ProfileLabelInfo,
} from './types'
import {RootStoreModel} from 'state/index'
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
| AppBskyEmbedExternal.View
| AppBskyEmbedRecordWithMedia.View
| {$type: string; [k: string]: unknown}
export function getLabelValueGroup(labelVal: string): LabelValGroup {
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
@ -17,3 +41,280 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
}
return UNKNOWN_LABEL_GROUP
}
export function getPostModeration(
store: RootStoreModel,
postInfo: PostLabelInfo,
): PostModeration {
const accountPref = store.preferences.getLabelPreference(
postInfo.accountLabels,
)
const profilePref = store.preferences.getLabelPreference(
postInfo.profileLabels,
)
const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
// avatar
let avatar = {
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
blur:
accountPref.pref === 'hide' ||
accountPref.pref === 'warn' ||
profilePref.pref === 'hide' ||
profilePref.pref === 'warn',
}
// hide no-override cases
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
return hidePostNoOverride(accountPref.desc.warning)
}
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
return hidePostNoOverride(profilePref.desc.warning)
}
if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
return hidePostNoOverride(postPref.desc.warning)
}
// hide cases
if (accountPref.pref === 'hide') {
return {
avatar,
list: hide(accountPref.desc.warning),
thread: hide(accountPref.desc.warning),
view: warn(accountPref.desc.warning),
}
}
if (profilePref.pref === 'hide') {
return {
avatar,
list: hide(profilePref.desc.warning),
thread: hide(profilePref.desc.warning),
view: warn(profilePref.desc.warning),
}
}
if (postPref.pref === 'hide') {
return {
avatar,
list: hide(postPref.desc.warning),
thread: hide(postPref.desc.warning),
view: warn(postPref.desc.warning),
}
}
// muting
if (postInfo.isMuted) {
return {
avatar,
list: hide('Post from an account you muted.'),
thread: warn('Post from an account you muted.'),
view: warn('Post from an account you muted.'),
}
}
// warning cases
if (postPref.pref === 'warn') {
if (postPref.desc.imagesOnly) {
return {
avatar,
list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
}
}
return {
avatar,
list: warnContent(postPref.desc.warning),
thread: warnContent(postPref.desc.warning),
view: warnContent(postPref.desc.warning),
}
}
if (accountPref.pref === 'warn') {
return {
avatar,
list: warnContent(accountPref.desc.warning),
thread: warnContent(accountPref.desc.warning),
view: warnContent(accountPref.desc.warning),
}
}
return {
avatar,
list: show(),
thread: show(),
view: show(),
}
}
export function getProfileModeration(
store: RootStoreModel,
profileLabels: ProfileLabelInfo,
): ProfileModeration {
const accountPref = store.preferences.getLabelPreference(
profileLabels.accountLabels,
)
const profilePref = store.preferences.getLabelPreference(
profileLabels.profileLabels,
)
// avatar
let avatar = {
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
blur:
accountPref.pref === 'hide' ||
accountPref.pref === 'warn' ||
profilePref.pref === 'hide' ||
profilePref.pref === 'warn',
}
// hide no-override cases
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
return hideProfileNoOverride(accountPref.desc.warning)
}
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
return hideProfileNoOverride(profilePref.desc.warning)
}
// hide cases
if (accountPref.pref === 'hide') {
return {
avatar,
list: hide(accountPref.desc.warning),
view: hide(accountPref.desc.warning),
}
}
if (profilePref.pref === 'hide') {
return {
avatar,
list: hide(profilePref.desc.warning),
view: hide(profilePref.desc.warning),
}
}
// warn cases
if (accountPref.pref === 'warn') {
return {
avatar,
list: warn(accountPref.desc.warning),
view: warn(accountPref.desc.warning),
}
}
// we don't warn for this
// if (profilePref.pref === 'warn') {
// return {
// avatar,
// list: warn(profilePref.desc.warning),
// view: warn(profilePref.desc.warning),
// }
// }
return {
avatar,
list: show(),
view: show(),
}
}
export function getProfileViewBasicLabelInfo(
profile: AppBskyActorDefs.ProfileViewBasic,
): ProfileLabelInfo {
return {
accountLabels: filterAccountLabels(profile.labels),
profileLabels: filterProfileLabels(profile.labels),
isMuted: profile.viewer?.muted || false,
}
}
export function getEmbedLabels(embed?: Embed): Label[] {
if (!embed) {
return []
}
if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.record.value).success
) {
return embed.record.record.labels || []
}
return []
}
export function filterAccountLabels(labels?: Label[]): Label[] {
if (!labels) {
return []
}
return labels.filter(
label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
)
}
export function filterProfileLabels(labels?: Label[]): Label[] {
if (!labels) {
return []
}
return labels.filter(label =>
label.uri.endsWith('/app.bsky.actor.profile/self'),
)
}
// internal methods
// =
function show() {
return {
behavior: ModerationBehaviorCode.Show,
}
}
function hidePostNoOverride(reason: string) {
return {
avatar: {warn: true, blur: true},
list: hideNoOverride(reason),
thread: hideNoOverride(reason),
view: hideNoOverride(reason),
}
}
function hideProfileNoOverride(reason: string) {
return {
avatar: {warn: true, blur: true},
list: hideNoOverride(reason),
view: hideNoOverride(reason),
}
}
function hideNoOverride(reason: string) {
return {
behavior: ModerationBehaviorCode.Hide,
reason,
noOverride: true,
}
}
function hide(reason: string) {
return {
behavior: ModerationBehaviorCode.Hide,
reason,
}
}
function warn(reason: string) {
return {
behavior: ModerationBehaviorCode.Warn,
reason,
}
}
function warnContent(reason: string) {
return {
behavior: ModerationBehaviorCode.WarnContent,
reason,
}
}
function warnImages(reason: string) {
return {
behavior: ModerationBehaviorCode.WarnImages,
reason,
}
}

58
src/lib/labeling/types.ts Normal file
View file

@ -0,0 +1,58 @@
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export type Label = ComAtprotoLabelDefs.Label
export interface LabelValGroup {
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
title: string
imagesOnly: boolean
subtitle?: string
warning: string
values: string[]
}
export interface PostLabelInfo {
postLabels: Label[]
accountLabels: Label[]
profileLabels: Label[]
isMuted: boolean
}
export interface ProfileLabelInfo {
accountLabels: Label[]
profileLabels: Label[]
isMuted: boolean
}
export enum ModerationBehaviorCode {
Show,
Hide,
Warn,
WarnContent,
WarnImages,
}
export interface ModerationBehavior {
behavior: ModerationBehaviorCode
noOverride?: boolean
reason?: string
}
export interface AvatarModeration {
warn: boolean
blur: boolean
}
export interface PostModeration {
avatar: AvatarModeration
list: ModerationBehavior
thread: ModerationBehavior
view: ModerationBehavior
}
export interface ProfileModeration {
avatar: AvatarModeration
list: ModerationBehavior
view: ModerationBehavior
}