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
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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
58
src/lib/labeling/types.ts
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue