From 876cdcffde49eb3e3b26077a9780b1ce8f69e588 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 7 Mar 2024 15:58:43 -0800 Subject: [PATCH] Add defaultSetting and adultOnly to custom label value definitions --- lexicons/com/atproto/label/defs.json | 10 + packages/api/src/client/lexicons.ts | 11 + .../client/types/com/atproto/label/defs.ts | 4 + packages/api/src/moderation/util.ts | 25 ++- packages/api/tests/moderation.test.ts | 209 ++++++++++++++++++ 5 files changed, 254 insertions(+), 5 deletions(-) diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json index cd8e03e116c..4d008dce2d6 100644 --- a/lexicons/com/atproto/label/defs.json +++ b/lexicons/com/atproto/label/defs.json @@ -83,6 +83,16 @@ "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", "knownValues": ["content", "media", "none"] }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, "locales": { "type": "array", "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index a04bf6bab6e..e93c28f6e9d 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2292,6 +2292,17 @@ export const schemaDict = { "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", knownValues: ['content', 'media', 'none'], }, + defaultSetting: { + type: 'string', + description: 'The default setting for this label.', + knownValues: ['ignore', 'warn', 'hide'], + default: 'warn', + }, + adultOnly: { + type: 'boolean', + description: + 'Does the user need to have adult content enabled in order to configure this label?', + }, locales: { type: 'array', items: { diff --git a/packages/api/src/client/types/com/atproto/label/defs.ts b/packages/api/src/client/types/com/atproto/label/defs.ts index c1641432c3a..89680b735c5 100644 --- a/packages/api/src/client/types/com/atproto/label/defs.ts +++ b/packages/api/src/client/types/com/atproto/label/defs.ts @@ -80,6 +80,10 @@ export interface LabelValueDefinition { severity: 'inform' | 'alert' | 'none' | (string & {}) /** What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing. */ blurs: 'content' | 'media' | 'none' | (string & {}) + /** The default setting for this label. */ + defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {}) + /** Does the user need to have adult content enabled in order to configure this label? */ + adultOnly?: boolean locales: LabelValueDefinitionStrings[] [k: string]: unknown } diff --git a/packages/api/src/moderation/util.ts b/packages/api/src/moderation/util.ts index f348cba1a20..50a9b64d82c 100644 --- a/packages/api/src/moderation/util.ts +++ b/packages/api/src/moderation/util.ts @@ -4,7 +4,12 @@ import { AppBskyLabelerDefs, ComAtprotoLabelDefs, } from '../client' -import { InterprettedLabelValueDefinition, ModerationBehavior } from './types' +import { + InterprettedLabelValueDefinition, + ModerationBehavior, + LabelPreference, + LabelValueDefinitionFlag, +} from './types' export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View { return Boolean(embed && AppBskyEmbedRecord.isView(embed)) @@ -40,7 +45,7 @@ export function interpretLabelValueDefinition( behaviors.account.profileList = alertOrInform behaviors.account.profileView = alertOrInform behaviors.account.contentList = 'blur' - behaviors.account.contentView = alertOrInform + behaviors.account.contentView = def.adultOnly ? 'blur' : alertOrInform // target=profile, blurs=content behaviors.account.profileView = alertOrInform behaviors.profile.avatar = 'blur' @@ -48,7 +53,7 @@ export function interpretLabelValueDefinition( behaviors.profile.displayName = 'blur' // target=content, blurs=content behaviors.content.contentList = 'blur' - behaviors.content.contentView = alertOrInform + behaviors.content.contentView = def.adultOnly ? 'blur' : alertOrInform } else if (def.blurs === 'media') { // target=account, blurs=media behaviors.account.profileList = alertOrInform @@ -75,12 +80,22 @@ export function interpretLabelValueDefinition( behaviors.content.contentView = alertOrInform } + let defaultSetting: LabelPreference = 'warn' + if (def.defaultSetting === 'hide' || def.defaultSetting === 'ignore') { + defaultSetting = def.defaultSetting as LabelPreference + } + + const flags: LabelValueDefinitionFlag[] = ['no-self'] + if (def.adultOnly) { + flags.push('adult') + } + return { ...def, definedBy, configurable: true, - defaultSetting: 'warn', - flags: ['no-self'], + defaultSetting, + flags, behaviors, } } diff --git a/packages/api/tests/moderation.test.ts b/packages/api/tests/moderation.test.ts index 8d025557c3a..6d4cc998ed0 100644 --- a/packages/api/tests/moderation.test.ts +++ b/packages/api/tests/moderation.test.ts @@ -441,4 +441,213 @@ describe('Moderation', () => { expect(res.ui('contentView')).toBeModerationResult([]) expect(res.ui('contentMedia')).toBeModerationResult([]) }) + + it('Custom labels can set the default setting', () => { + const modOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: true, + labels: {}, + labelers: [ + { + did: 'did:web:labeler.test', + labels: {}, + }, + ], + }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'default-hide', + blurs: 'content', + severity: 'inform', + defaultSetting: 'hide', + locales: [], + }, + 'did:web:labeler.test', + ), + interpretLabelValueDefinition( + { + identifier: 'default-warn', + blurs: 'content', + severity: 'inform', + defaultSetting: 'warn', + locales: [], + }, + 'did:web:labeler.test', + ), + interpretLabelValueDefinition( + { + identifier: 'default-ignore', + blurs: 'content', + severity: 'inform', + defaultSetting: 'ignore', + locales: [], + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res1 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-hide', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, + ) + + expect(res1.ui('profileList')).toBeModerationResult(['filter']) + expect(res1.ui('profileView')).toBeModerationResult([]) + expect(res1.ui('avatar')).toBeModerationResult([]) + expect(res1.ui('banner')).toBeModerationResult([]) + expect(res1.ui('displayName')).toBeModerationResult([]) + expect(res1.ui('contentList')).toBeModerationResult(['filter', 'blur']) + expect(res1.ui('contentView')).toBeModerationResult(['inform']) + expect(res1.ui('contentMedia')).toBeModerationResult([]) + + const res2 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-warn', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, + ) + + expect(res2.ui('profileList')).toBeModerationResult([]) + expect(res2.ui('profileView')).toBeModerationResult([]) + expect(res2.ui('avatar')).toBeModerationResult([]) + expect(res2.ui('banner')).toBeModerationResult([]) + expect(res2.ui('displayName')).toBeModerationResult([]) + expect(res2.ui('contentList')).toBeModerationResult(['blur']) + expect(res2.ui('contentView')).toBeModerationResult(['inform']) + expect(res2.ui('contentMedia')).toBeModerationResult([]) + + const res3 = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'default-ignore', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, + ) + + expect(res3.ui('profileList')).toBeModerationResult([]) + expect(res3.ui('profileView')).toBeModerationResult([]) + expect(res3.ui('avatar')).toBeModerationResult([]) + expect(res3.ui('banner')).toBeModerationResult([]) + expect(res3.ui('displayName')).toBeModerationResult([]) + expect(res3.ui('contentList')).toBeModerationResult([]) + expect(res3.ui('contentView')).toBeModerationResult([]) + expect(res3.ui('contentMedia')).toBeModerationResult([]) + }) + + it('Custom labels can require adult content to be enabled', () => { + const modOpts = { + userDid: 'did:web:alice.test', + prefs: { + adultContentEnabled: false, + labels: { adult: 'ignore' }, + labelers: [ + { + did: 'did:web:labeler.test', + labels: { + adult: 'ignore', + }, + }, + ], + }, + labelDefs: { + 'did:web:labeler.test': [ + interpretLabelValueDefinition( + { + identifier: 'adult', + blurs: 'content', + severity: 'inform', + defaultSetting: 'hide', + adultOnly: true, + locales: [], + }, + 'did:web:labeler.test', + ), + ], + }, + } + const res = moderatePost( + mock.postView({ + record: { + text: 'Hello', + createdAt: new Date().toISOString(), + }, + author: mock.profileViewBasic({ + handle: 'bob.test', + displayName: 'Bob', + }), + labels: [ + { + src: 'did:web:labeler.test', + uri: 'at://did:web:bob.test/app.bsky.post/fake', + val: 'adult', + cts: new Date().toISOString(), + }, + ], + }), + modOpts, + ) + + expect(res.ui('profileList')).toBeModerationResult(['filter']) + expect(res.ui('profileView')).toBeModerationResult([]) + expect(res.ui('avatar')).toBeModerationResult([]) + expect(res.ui('banner')).toBeModerationResult([]) + expect(res.ui('displayName')).toBeModerationResult([]) + expect(res.ui('contentList')).toBeModerationResult([ + 'filter', + 'blur', + 'noOverride', + ]) + expect(res.ui('contentView')).toBeModerationResult(['blur', 'noOverride']) + expect(res.ui('contentMedia')).toBeModerationResult([]) + }) })