[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Configured appview codegen

added codegen dependencies

Honys 3e2a7bbf b2ba91eb

+1895 -5
+170
services/appview/definitions/labels.json
··· 1 + [ 2 + { 3 + "identifier": "!hide", 4 + "configurable": false, 5 + "defaultSetting": "hide", 6 + "flags": ["no-override", "no-self"], 7 + "severity": "alert", 8 + "blurs": "content", 9 + "behaviors": { 10 + "account": { 11 + "profileList": "blur", 12 + "profileView": "blur", 13 + "avatar": "blur", 14 + "banner": "blur", 15 + "displayName": "blur", 16 + "contentList": "blur", 17 + "contentView": "blur" 18 + }, 19 + "profile": { 20 + "avatar": "blur", 21 + "banner": "blur", 22 + "displayName": "blur" 23 + }, 24 + "content": { 25 + "contentList": "blur", 26 + "contentView": "blur" 27 + } 28 + } 29 + }, 30 + { 31 + "identifier": "!warn", 32 + "configurable": false, 33 + "defaultSetting": "warn", 34 + "flags": ["no-self"], 35 + "severity": "none", 36 + "blurs": "content", 37 + "behaviors": { 38 + "account": { 39 + "profileList": "blur", 40 + "profileView": "blur", 41 + "avatar": "blur", 42 + "banner": "blur", 43 + "contentList": "blur", 44 + "contentView": "blur" 45 + }, 46 + "profile": { 47 + "avatar": "blur", 48 + "banner": "blur", 49 + "displayName": "blur" 50 + }, 51 + "content": { 52 + "contentList": "blur", 53 + "contentView": "blur" 54 + } 55 + } 56 + }, 57 + { 58 + "identifier": "!no-unauthenticated", 59 + "configurable": false, 60 + "defaultSetting": "hide", 61 + "flags": ["no-override", "unauthed"], 62 + "severity": "none", 63 + "blurs": "content", 64 + "behaviors": { 65 + "account": { 66 + "profileList": "blur", 67 + "profileView": "blur", 68 + "avatar": "blur", 69 + "banner": "blur", 70 + "displayName": "blur", 71 + "contentList": "blur", 72 + "contentView": "blur" 73 + }, 74 + "profile": { 75 + "avatar": "blur", 76 + "banner": "blur", 77 + "displayName": "blur" 78 + }, 79 + "content": { 80 + "contentList": "blur", 81 + "contentView": "blur" 82 + } 83 + } 84 + }, 85 + { 86 + "identifier": "porn", 87 + "configurable": true, 88 + "defaultSetting": "hide", 89 + "flags": ["adult"], 90 + "severity": "none", 91 + "blurs": "media", 92 + "behaviors": { 93 + "account": { 94 + "avatar": "blur", 95 + "banner": "blur" 96 + }, 97 + "profile": { 98 + "avatar": "blur", 99 + "banner": "blur" 100 + }, 101 + "content": { 102 + "contentMedia": "blur" 103 + } 104 + } 105 + }, 106 + { 107 + "identifier": "sexual", 108 + "configurable": true, 109 + "defaultSetting": "warn", 110 + "flags": ["adult"], 111 + "severity": "none", 112 + "blurs": "media", 113 + "behaviors": { 114 + "account": { 115 + "avatar": "blur", 116 + "banner": "blur" 117 + }, 118 + "profile": { 119 + "avatar": "blur", 120 + "banner": "blur" 121 + }, 122 + "content": { 123 + "contentMedia": "blur" 124 + } 125 + } 126 + }, 127 + { 128 + "identifier": "nudity", 129 + "configurable": true, 130 + "defaultSetting": "ignore", 131 + "flags": [], 132 + "severity": "none", 133 + "blurs": "media", 134 + "behaviors": { 135 + "account": { 136 + "avatar": "blur", 137 + "banner": "blur" 138 + }, 139 + "profile": { 140 + "avatar": "blur", 141 + "banner": "blur" 142 + }, 143 + "content": { 144 + "contentMedia": "blur" 145 + } 146 + } 147 + }, 148 + { 149 + "identifier": "graphic-media", 150 + "alias": ["gore"], 151 + "flags": ["adult"], 152 + "configurable": true, 153 + "defaultSetting": "warn", 154 + "severity": "none", 155 + "blurs": "media", 156 + "behaviors": { 157 + "account": { 158 + "avatar": "blur", 159 + "banner": "blur" 160 + }, 161 + "profile": { 162 + "avatar": "blur", 163 + "banner": "blur" 164 + }, 165 + "content": { 166 + "contentMedia": "blur" 167 + } 168 + } 169 + } 170 + ]
+3 -1
services/appview/package.json
··· 2 2 "name": "app", 3 3 "version": "1.0.50", 4 4 "scripts": { 5 - "codegen": "lex gen-server --yes src/lexicons lexicons/com/atproto/*/* lexicons/app/bsky/*/*", 5 + "codegen": "node ./scripts/generate-code.mjs && lex gen-api --yes ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/so/sprk/*/*", 6 6 "test": "echo \"Error: no test specified\" && exit 1", 7 7 "dev": "tsx watch src/index.ts", 8 8 "build": "tsc src/index.ts --outDir dist", ··· 15 15 "@atproto/sync": "^0.1.14", 16 16 "@atproto/syntax": "^0.3.3", 17 17 "@atproto/xrpc-server": "^0.7.11", 18 + "multiformats": "^9.9.0", 18 19 "dotenv": "^16.4.7", 19 20 "envalid": "^8.0.0", 20 21 "express": "^4.21.2", ··· 29 30 "@types/node": "^22.13.5", 30 31 "@types/pg": "^8.11.11", 31 32 "bun-types": "latest", 33 + "prettier": "^3.2.5", 32 34 "tsx": "^4.19.3", 33 35 "typescript": "^5.7.3" 34 36 },
+10 -4
services/appview/pnpm-lock.yaml
··· 41 41 kysely: 42 42 specifier: ^0.27.5 43 43 version: 0.27.5 44 + multiformats: 45 + specifier: ^9.9.0 46 + version: 9.9.0 44 47 pg: 45 48 specifier: ^8.13.3 46 49 version: 8.13.3 ··· 63 66 bun-types: 64 67 specifier: latest 65 68 version: 1.2.2 69 + prettier: 70 + specifier: ^3.2.5 71 + version: 3.2.5 66 72 tsx: 67 73 specifier: ^4.19.3 68 74 version: 4.19.3 ··· 918 924 postgres-range@1.1.4: 919 925 resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} 920 926 921 - prettier@3.5.1: 922 - resolution: {integrity: sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==} 927 + prettier@3.2.5: 928 + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} 923 929 engines: {node: '>=14'} 924 930 hasBin: true 925 931 ··· 1231 1237 '@atproto/syntax': 0.3.3 1232 1238 chalk: 4.1.2 1233 1239 commander: 9.5.0 1234 - prettier: 3.5.1 1240 + prettier: 3.2.5 1235 1241 ts-morph: 16.0.0 1236 1242 yesno: 0.4.0 1237 1243 zod: 3.24.2 ··· 2041 2047 2042 2048 postgres-range@1.1.4: {} 2043 2049 2044 - prettier@3.5.1: {} 2050 + prettier@3.2.5: {} 2045 2051 2046 2052 process-warning@3.0.0: {} 2047 2053
+74
services/appview/scripts/code/labels.mjs
··· 1 + import * as url from 'url' 2 + import { readFileSync, writeFileSync } from 'fs' 3 + import { join } from 'path' 4 + import * as prettier from 'prettier' 5 + 6 + const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 7 + 8 + const labelsDef = JSON.parse( 9 + readFileSync( 10 + join(__dirname, '..', '..', 'definitions', 'labels.json'), 11 + 'utf8', 12 + ), 13 + ) 14 + 15 + writeFileSync( 16 + join(__dirname, '..', '..', 'src', 'moderation', 'const', 'labels.ts'), 17 + await gen(), 18 + 'utf8', 19 + ) 20 + 21 + async function gen() { 22 + const knownValues = new Set() 23 + const flattenedLabelDefs = [] 24 + 25 + for (const { alias: aliases, ...label } of labelsDef) { 26 + knownValues.add(label.identifier) 27 + flattenedLabelDefs.push([label.identifier, { ...label, locales: [] }]) 28 + 29 + if (aliases) { 30 + for (const alias of aliases) { 31 + knownValues.add(alias) 32 + flattenedLabelDefs.push([ 33 + alias, 34 + { 35 + ...label, 36 + identifier: alias, 37 + locales: [], 38 + comment: `@deprecated alias for \`${label.identifier}\``, 39 + }, 40 + ]) 41 + } 42 + } 43 + } 44 + 45 + let labelDefsStr = `{` 46 + for (const [key, { comment, ...value }] of flattenedLabelDefs) { 47 + const commentStr = comment ? `\n/** ${comment} */\n` : '' 48 + labelDefsStr += `${commentStr}'${key}': ${JSON.stringify(value, null, 2)},` 49 + } 50 + labelDefsStr += `}` 51 + 52 + return prettier.format( 53 + `/** this doc is generated by ./scripts/code/labels.mjs **/ 54 + import {InterpretedLabelValueDefinition, LabelPreference} from '../types' 55 + 56 + export type KnownLabelValue = ${Array.from(knownValues) 57 + .map((value) => `"${value}"`) 58 + .join(' | ')} 59 + 60 + export const DEFAULT_LABEL_SETTINGS: Record<string, LabelPreference> = ${JSON.stringify( 61 + Object.fromEntries( 62 + labelsDef 63 + .filter((label) => label.configurable) 64 + .map((label) => [label.identifier, label.defaultSetting]), 65 + ), 66 + )} 67 + 68 + export const LABELS: Record<KnownLabelValue, InterpretedLabelValueDefinition> = ${labelDefsStr} 69 + `, 70 + { semi: false, parser: 'typescript', singleQuote: true }, 71 + ) 72 + } 73 + 74 + export {}
+3
services/appview/scripts/generate-code.mjs
··· 1 + import './code/labels.mjs' 2 + 3 + export {}
+220
services/appview/src/moderation/const/labels.ts
··· 1 + /** this doc is generated by ./scripts/code/labels.mjs **/ 2 + import { InterpretedLabelValueDefinition, LabelPreference } from '../types' 3 + 4 + export type KnownLabelValue = 5 + | '!hide' 6 + | '!warn' 7 + | '!no-unauthenticated' 8 + | 'porn' 9 + | 'sexual' 10 + | 'nudity' 11 + | 'graphic-media' 12 + | 'gore' 13 + 14 + export const DEFAULT_LABEL_SETTINGS: Record<string, LabelPreference> = { 15 + porn: 'hide', 16 + sexual: 'warn', 17 + nudity: 'ignore', 18 + 'graphic-media': 'warn', 19 + } 20 + 21 + export const LABELS: Record<KnownLabelValue, InterpretedLabelValueDefinition> = 22 + { 23 + '!hide': { 24 + identifier: '!hide', 25 + configurable: false, 26 + defaultSetting: 'hide', 27 + flags: ['no-override', 'no-self'], 28 + severity: 'alert', 29 + blurs: 'content', 30 + behaviors: { 31 + account: { 32 + profileList: 'blur', 33 + profileView: 'blur', 34 + avatar: 'blur', 35 + banner: 'blur', 36 + displayName: 'blur', 37 + contentList: 'blur', 38 + contentView: 'blur', 39 + }, 40 + profile: { 41 + avatar: 'blur', 42 + banner: 'blur', 43 + displayName: 'blur', 44 + }, 45 + content: { 46 + contentList: 'blur', 47 + contentView: 'blur', 48 + }, 49 + }, 50 + locales: [], 51 + }, 52 + '!warn': { 53 + identifier: '!warn', 54 + configurable: false, 55 + defaultSetting: 'warn', 56 + flags: ['no-self'], 57 + severity: 'none', 58 + blurs: 'content', 59 + behaviors: { 60 + account: { 61 + profileList: 'blur', 62 + profileView: 'blur', 63 + avatar: 'blur', 64 + banner: 'blur', 65 + contentList: 'blur', 66 + contentView: 'blur', 67 + }, 68 + profile: { 69 + avatar: 'blur', 70 + banner: 'blur', 71 + displayName: 'blur', 72 + }, 73 + content: { 74 + contentList: 'blur', 75 + contentView: 'blur', 76 + }, 77 + }, 78 + locales: [], 79 + }, 80 + '!no-unauthenticated': { 81 + identifier: '!no-unauthenticated', 82 + configurable: false, 83 + defaultSetting: 'hide', 84 + flags: ['no-override', 'unauthed'], 85 + severity: 'none', 86 + blurs: 'content', 87 + behaviors: { 88 + account: { 89 + profileList: 'blur', 90 + profileView: 'blur', 91 + avatar: 'blur', 92 + banner: 'blur', 93 + displayName: 'blur', 94 + contentList: 'blur', 95 + contentView: 'blur', 96 + }, 97 + profile: { 98 + avatar: 'blur', 99 + banner: 'blur', 100 + displayName: 'blur', 101 + }, 102 + content: { 103 + contentList: 'blur', 104 + contentView: 'blur', 105 + }, 106 + }, 107 + locales: [], 108 + }, 109 + porn: { 110 + identifier: 'porn', 111 + configurable: true, 112 + defaultSetting: 'hide', 113 + flags: ['adult'], 114 + severity: 'none', 115 + blurs: 'media', 116 + behaviors: { 117 + account: { 118 + avatar: 'blur', 119 + banner: 'blur', 120 + }, 121 + profile: { 122 + avatar: 'blur', 123 + banner: 'blur', 124 + }, 125 + content: { 126 + contentMedia: 'blur', 127 + }, 128 + }, 129 + locales: [], 130 + }, 131 + sexual: { 132 + identifier: 'sexual', 133 + configurable: true, 134 + defaultSetting: 'warn', 135 + flags: ['adult'], 136 + severity: 'none', 137 + blurs: 'media', 138 + behaviors: { 139 + account: { 140 + avatar: 'blur', 141 + banner: 'blur', 142 + }, 143 + profile: { 144 + avatar: 'blur', 145 + banner: 'blur', 146 + }, 147 + content: { 148 + contentMedia: 'blur', 149 + }, 150 + }, 151 + locales: [], 152 + }, 153 + nudity: { 154 + identifier: 'nudity', 155 + configurable: true, 156 + defaultSetting: 'ignore', 157 + flags: [], 158 + severity: 'none', 159 + blurs: 'media', 160 + behaviors: { 161 + account: { 162 + avatar: 'blur', 163 + banner: 'blur', 164 + }, 165 + profile: { 166 + avatar: 'blur', 167 + banner: 'blur', 168 + }, 169 + content: { 170 + contentMedia: 'blur', 171 + }, 172 + }, 173 + locales: [], 174 + }, 175 + 'graphic-media': { 176 + identifier: 'graphic-media', 177 + flags: ['adult'], 178 + configurable: true, 179 + defaultSetting: 'warn', 180 + severity: 'none', 181 + blurs: 'media', 182 + behaviors: { 183 + account: { 184 + avatar: 'blur', 185 + banner: 'blur', 186 + }, 187 + profile: { 188 + avatar: 'blur', 189 + banner: 'blur', 190 + }, 191 + content: { 192 + contentMedia: 'blur', 193 + }, 194 + }, 195 + locales: [], 196 + }, 197 + /** @deprecated alias for `graphic-media` */ 198 + gore: { 199 + identifier: 'gore', 200 + flags: ['adult'], 201 + configurable: true, 202 + defaultSetting: 'warn', 203 + severity: 'none', 204 + blurs: 'media', 205 + behaviors: { 206 + account: { 207 + avatar: 'blur', 208 + banner: 'blur', 209 + }, 210 + profile: { 211 + avatar: 'blur', 212 + banner: 'blur', 213 + }, 214 + content: { 215 + contentMedia: 'blur', 216 + }, 217 + }, 218 + locales: [], 219 + }, 220 + }
+387
services/appview/src/moderation/decision.ts
··· 1 + import { AppBskyGraphDefs } from '../client/index' 2 + import { LABELS } from './const/labels' 3 + import { 4 + BLOCK_BEHAVIOR, 5 + CUSTOM_LABEL_VALUE_RE, 6 + HIDE_BEHAVIOR, 7 + Label, 8 + LabelPreference, 9 + LabelTarget, 10 + MUTEWORD_BEHAVIOR, 11 + MUTE_BEHAVIOR, 12 + ModerationBehavior, 13 + ModerationCause, 14 + ModerationOpts, 15 + NOOP_BEHAVIOR, 16 + } from './types' 17 + import { ModerationUI } from './ui' 18 + 19 + enum ModerationBehaviorSeverity { 20 + High, 21 + Medium, 22 + Low, 23 + } 24 + 25 + export class ModerationDecision { 26 + did = '' 27 + isMe = false 28 + causes: ModerationCause[] = [] 29 + 30 + constructor() {} 31 + 32 + static merge( 33 + ...decisions: (ModerationDecision | undefined)[] 34 + ): ModerationDecision { 35 + const decisionsFiltered = decisions.filter((v) => v != null) 36 + const decision = new ModerationDecision() 37 + if (decisionsFiltered[0]) { 38 + decision.did = decisionsFiltered[0].did 39 + decision.isMe = decisionsFiltered[0].isMe 40 + } 41 + decision.causes = decisionsFiltered.flatMap((d) => d.causes) 42 + return decision 43 + } 44 + 45 + downgrade() { 46 + for (const cause of this.causes) { 47 + cause.downgraded = true 48 + } 49 + return this 50 + } 51 + 52 + get blocked() { 53 + return !!this.blockCause 54 + } 55 + 56 + get muted() { 57 + return !!this.muteCause 58 + } 59 + 60 + get blockCause() { 61 + return this.causes.find( 62 + (cause) => 63 + cause.type === 'blocking' || 64 + cause.type === 'blocked-by' || 65 + cause.type === 'block-other', 66 + ) 67 + } 68 + 69 + get muteCause() { 70 + return this.causes.find((cause) => cause.type === 'muted') 71 + } 72 + 73 + get labelCauses() { 74 + return this.causes.filter((cause) => cause.type === 'label') 75 + } 76 + 77 + ui(context: keyof ModerationBehavior): ModerationUI { 78 + const ui = new ModerationUI() 79 + for (const cause of this.causes) { 80 + if ( 81 + cause.type === 'blocking' || 82 + cause.type === 'blocked-by' || 83 + cause.type === 'block-other' 84 + ) { 85 + if (this.isMe) { 86 + continue 87 + } 88 + if (context === 'profileList' || context === 'contentList') { 89 + ui.filters.push(cause) 90 + } 91 + if (!cause.downgraded) { 92 + if (BLOCK_BEHAVIOR[context] === 'blur') { 93 + ui.noOverride = true 94 + ui.blurs.push(cause) 95 + } else if (BLOCK_BEHAVIOR[context] === 'alert') { 96 + ui.alerts.push(cause) 97 + } else if (BLOCK_BEHAVIOR[context] === 'inform') { 98 + ui.informs.push(cause) 99 + } 100 + } 101 + } else if (cause.type === 'muted') { 102 + if (this.isMe) { 103 + continue 104 + } 105 + if (context === 'profileList' || context === 'contentList') { 106 + ui.filters.push(cause) 107 + } 108 + if (!cause.downgraded) { 109 + if (MUTE_BEHAVIOR[context] === 'blur') { 110 + ui.blurs.push(cause) 111 + } else if (MUTE_BEHAVIOR[context] === 'alert') { 112 + ui.alerts.push(cause) 113 + } else if (MUTE_BEHAVIOR[context] === 'inform') { 114 + ui.informs.push(cause) 115 + } 116 + } 117 + } else if (cause.type === 'mute-word') { 118 + if (this.isMe) { 119 + continue 120 + } 121 + if (context === 'contentList') { 122 + ui.filters.push(cause) 123 + } 124 + if (!cause.downgraded) { 125 + if (MUTEWORD_BEHAVIOR[context] === 'blur') { 126 + ui.blurs.push(cause) 127 + } else if (MUTEWORD_BEHAVIOR[context] === 'alert') { 128 + ui.alerts.push(cause) 129 + } else if (MUTEWORD_BEHAVIOR[context] === 'inform') { 130 + ui.informs.push(cause) 131 + } 132 + } 133 + } else if (cause.type === 'hidden') { 134 + if (context === 'profileList' || context === 'contentList') { 135 + ui.filters.push(cause) 136 + } 137 + if (!cause.downgraded) { 138 + if (HIDE_BEHAVIOR[context] === 'blur') { 139 + ui.blurs.push(cause) 140 + } else if (HIDE_BEHAVIOR[context] === 'alert') { 141 + ui.alerts.push(cause) 142 + } else if (HIDE_BEHAVIOR[context] === 'inform') { 143 + ui.informs.push(cause) 144 + } 145 + } 146 + } else if (cause.type === 'label') { 147 + if (context === 'profileList' && cause.target === 'account') { 148 + if (cause.setting === 'hide' && !this.isMe) { 149 + ui.filters.push(cause) 150 + } 151 + } else if ( 152 + context === 'contentList' && 153 + (cause.target === 'account' || cause.target === 'content') 154 + ) { 155 + if (cause.setting === 'hide' && !this.isMe) { 156 + ui.filters.push(cause) 157 + } 158 + } 159 + if (!cause.downgraded) { 160 + if (cause.behavior[context] === 'blur') { 161 + ui.blurs.push(cause) 162 + if (cause.noOverride && !this.isMe) { 163 + ui.noOverride = true 164 + } 165 + } else if (cause.behavior[context] === 'alert') { 166 + ui.alerts.push(cause) 167 + } else if (cause.behavior[context] === 'inform') { 168 + ui.informs.push(cause) 169 + } 170 + } 171 + } 172 + } 173 + 174 + ui.filters.sort(sortByPriority) 175 + ui.blurs.sort(sortByPriority) 176 + 177 + return ui 178 + } 179 + 180 + setDid(did: string) { 181 + this.did = did 182 + } 183 + 184 + setIsMe(isMe: boolean) { 185 + this.isMe = isMe 186 + } 187 + 188 + addHidden(hidden: boolean) { 189 + if (hidden) { 190 + this.causes.push({ 191 + type: 'hidden', 192 + source: { type: 'user' }, 193 + priority: 6, 194 + }) 195 + } 196 + } 197 + 198 + addMutedWord(mutedWord: boolean) { 199 + if (mutedWord) { 200 + this.causes.push({ 201 + type: 'mute-word', 202 + source: { type: 'user' }, 203 + priority: 6, 204 + }) 205 + } 206 + } 207 + 208 + addBlocking(blocking: string | undefined) { 209 + if (blocking) { 210 + this.causes.push({ 211 + type: 'blocking', 212 + source: { type: 'user' }, 213 + priority: 3, 214 + }) 215 + } 216 + } 217 + 218 + addBlockingByList( 219 + blockingByList: AppBskyGraphDefs.ListViewBasic | undefined, 220 + ) { 221 + if (blockingByList) { 222 + this.causes.push({ 223 + type: 'blocking', 224 + source: { type: 'list', list: blockingByList }, 225 + priority: 3, 226 + }) 227 + } 228 + } 229 + 230 + addBlockedBy(blockedBy: boolean | undefined) { 231 + if (blockedBy) { 232 + this.causes.push({ 233 + type: 'blocked-by', 234 + source: { type: 'user' }, 235 + priority: 4, 236 + }) 237 + } 238 + } 239 + 240 + addBlockOther(blockOther: boolean | undefined) { 241 + if (blockOther) { 242 + this.causes.push({ 243 + type: 'block-other', 244 + source: { type: 'user' }, 245 + priority: 4, 246 + }) 247 + } 248 + } 249 + 250 + addLabel(target: LabelTarget, label: Label, opts: ModerationOpts) { 251 + // look up the label definition 252 + const labelDef = CUSTOM_LABEL_VALUE_RE.test(label.val) 253 + ? opts.labelDefs?.[label.src]?.find( 254 + (def) => def.identifier === label.val, 255 + ) || LABELS[label.val] 256 + : LABELS[label.val] 257 + if (!labelDef) { 258 + // ignore labels we don't understand 259 + return 260 + } 261 + 262 + // look up the label preference 263 + const isSelf = label.src === this.did 264 + const labeler = isSelf 265 + ? undefined 266 + : opts.prefs.labelers.find((s) => s.did === label.src) 267 + 268 + if (!isSelf && !labeler) { 269 + return // skip labelers not configured by the user 270 + } 271 + if (isSelf && labelDef.flags.includes('no-self')) { 272 + return // skip self-labels that aren't supported 273 + } 274 + 275 + // establish the label preference for interpretation 276 + let labelPref: LabelPreference = labelDef.defaultSetting || 'ignore' 277 + if (!labelDef.configurable) { 278 + labelPref = labelDef.defaultSetting || 'hide' 279 + } else if ( 280 + labelDef.flags.includes('adult') && 281 + !opts.prefs.adultContentEnabled 282 + ) { 283 + labelPref = 'hide' 284 + } else if (labeler?.labels[labelDef.identifier]) { 285 + labelPref = labeler?.labels[labelDef.identifier] 286 + } else if (opts.prefs.labels[labelDef.identifier]) { 287 + labelPref = opts.prefs.labels[labelDef.identifier] 288 + } 289 + 290 + // ignore labels the user has asked to ignore 291 + if (labelPref === 'ignore') { 292 + return 293 + } 294 + 295 + // ignore 'unauthed' labels when the user is authed 296 + if (labelDef.flags.includes('unauthed') && !!opts.userDid) { 297 + return 298 + } 299 + 300 + // establish the priority of the label 301 + let priority: 1 | 2 | 5 | 7 | 8 302 + const severity = measureModerationBehaviorSeverity( 303 + labelDef.behaviors[target], 304 + ) 305 + if ( 306 + labelDef.flags.includes('no-override') || 307 + (labelDef.flags.includes('adult') && !opts.prefs.adultContentEnabled) 308 + ) { 309 + priority = 1 310 + } else if (labelPref === 'hide') { 311 + priority = 2 312 + } else if (severity === ModerationBehaviorSeverity.High) { 313 + // blurring profile view or content view 314 + priority = 5 315 + } else if (severity === ModerationBehaviorSeverity.Medium) { 316 + // blurring content list or content media 317 + priority = 7 318 + } else { 319 + // blurring avatar, adding alerts 320 + priority = 8 321 + } 322 + 323 + let noOverride = false 324 + if (labelDef.flags.includes('no-override')) { 325 + noOverride = true 326 + } else if ( 327 + labelDef.flags.includes('adult') && 328 + !opts.prefs.adultContentEnabled 329 + ) { 330 + noOverride = true 331 + } 332 + 333 + this.causes.push({ 334 + type: 'label', 335 + source: 336 + isSelf || !labeler 337 + ? { type: 'user' } 338 + : { type: 'labeler', did: labeler.did }, 339 + label, 340 + labelDef, 341 + target, 342 + setting: labelPref, 343 + behavior: labelDef.behaviors[target] || NOOP_BEHAVIOR, 344 + noOverride, 345 + priority, 346 + }) 347 + } 348 + 349 + addMuted(muted: boolean | undefined) { 350 + if (muted) { 351 + this.causes.push({ 352 + type: 'muted', 353 + source: { type: 'user' }, 354 + priority: 6, 355 + }) 356 + } 357 + } 358 + 359 + addMutedByList(mutedByList: AppBskyGraphDefs.ListViewBasic | undefined) { 360 + if (mutedByList) { 361 + this.causes.push({ 362 + type: 'muted', 363 + source: { type: 'list', list: mutedByList }, 364 + priority: 6, 365 + }) 366 + } 367 + } 368 + } 369 + 370 + function measureModerationBehaviorSeverity( 371 + beh: ModerationBehavior | undefined, 372 + ): ModerationBehaviorSeverity { 373 + if (!beh) { 374 + return ModerationBehaviorSeverity.Low 375 + } 376 + if (beh.profileView === 'blur' || beh.contentView === 'blur') { 377 + return ModerationBehaviorSeverity.High 378 + } 379 + if (beh.contentList === 'blur' || beh.contentMedia === 'blur') { 380 + return ModerationBehaviorSeverity.Medium 381 + } 382 + return ModerationBehaviorSeverity.Low 383 + } 384 + 385 + function sortByPriority(a: ModerationCause, b: ModerationCause) { 386 + return a.priority - b.priority 387 + }
+61
services/appview/src/moderation/index.ts
··· 1 + import { ModerationDecision } from './decision' 2 + import { decideAccount } from './subjects/account' 3 + import { decideFeedGenerator } from './subjects/feed-generator' 4 + import { decideNotification } from './subjects/notification' 5 + import { decidePost } from './subjects/post' 6 + import { decideProfile } from './subjects/profile' 7 + import { decideUserList } from './subjects/user-list' 8 + import { 9 + ModerationOpts, 10 + ModerationSubjectFeedGenerator, 11 + ModerationSubjectNotification, 12 + ModerationSubjectPost, 13 + ModerationSubjectProfile, 14 + ModerationSubjectUserList, 15 + } from './types' 16 + 17 + export { ModerationUI } from './ui' 18 + export { ModerationDecision } from './decision' 19 + export { hasMutedWord } from './mutewords' 20 + export { 21 + interpretLabelValueDefinition, 22 + interpretLabelValueDefinitions, 23 + } from './util' 24 + 25 + export function moderateProfile( 26 + subject: ModerationSubjectProfile, 27 + opts: ModerationOpts, 28 + ): ModerationDecision { 29 + return ModerationDecision.merge( 30 + decideAccount(subject, opts), 31 + decideProfile(subject, opts), 32 + ) 33 + } 34 + 35 + export function moderatePost( 36 + subject: ModerationSubjectPost, 37 + opts: ModerationOpts, 38 + ): ModerationDecision { 39 + return decidePost(subject, opts) 40 + } 41 + 42 + export function moderateNotification( 43 + subject: ModerationSubjectNotification, 44 + opts: ModerationOpts, 45 + ): ModerationDecision { 46 + return decideNotification(subject, opts) 47 + } 48 + 49 + export function moderateFeedGenerator( 50 + subject: ModerationSubjectFeedGenerator, 51 + opts: ModerationOpts, 52 + ): ModerationDecision { 53 + return decideFeedGenerator(subject, opts) 54 + } 55 + 56 + export function moderateUserList( 57 + subject: ModerationSubjectUserList, 58 + opts: ModerationOpts, 59 + ): ModerationDecision { 60 + return decideUserList(subject, opts) 61 + }
+108
services/appview/src/moderation/mutewords.ts
··· 1 + import { AppBskyActorDefs, AppBskyRichtextFacet } from '../client' 2 + 3 + const REGEX = { 4 + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, 5 + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, 6 + SEPARATORS: /[/\-–—()[\]_]+/g, 7 + WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, 8 + } 9 + 10 + /** 11 + * List of 2-letter lang codes for languages that either don't use spaces, or 12 + * don't use spaces in a way conducive to word-based filtering. 13 + * 14 + * For these, we use a simple `String.includes` to check for a match. 15 + */ 16 + const LANGUAGE_EXCEPTIONS = [ 17 + 'ja', // Japanese 18 + 'zh', // Chinese 19 + 'ko', // Korean 20 + 'th', // Thai 21 + 'vi', // Vietnamese 22 + ] 23 + 24 + export function hasMutedWord({ 25 + mutedWords, 26 + text, 27 + facets, 28 + outlineTags, 29 + languages, 30 + actor, 31 + }: { 32 + mutedWords: AppBskyActorDefs.MutedWord[] 33 + text: string 34 + facets?: AppBskyRichtextFacet.Main[] 35 + outlineTags?: string[] 36 + languages?: string[] 37 + actor?: AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewBasic 38 + }) { 39 + const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') 40 + const tags = ([] as string[]) 41 + .concat(outlineTags || []) 42 + .concat( 43 + (facets || []).flatMap((facet) => 44 + facet.features.filter(AppBskyRichtextFacet.isTag).map((tag) => tag.tag), 45 + ), 46 + ) 47 + .map((t) => t.toLowerCase()) 48 + 49 + for (const mute of mutedWords) { 50 + const mutedWord = mute.value.toLowerCase() 51 + const postText = text.toLowerCase() 52 + 53 + // expired, ignore 54 + if (mute.expiresAt && mute.expiresAt < new Date().toISOString()) continue 55 + 56 + if ( 57 + mute.actorTarget === 'exclude-following' && 58 + Boolean(actor?.viewer?.following) 59 + ) 60 + continue 61 + 62 + // `content` applies to tags as well 63 + if (tags.includes(mutedWord)) return true 64 + // rest of the checks are for `content` only 65 + if (!mute.targets.includes('content')) continue 66 + // single character or other exception, has to use includes 67 + if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord)) 68 + return true 69 + // too long 70 + if (mutedWord.length > postText.length) continue 71 + // exact match 72 + if (mutedWord === postText) return true 73 + // any muted phrase with space or punctuation 74 + if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) 75 + return true 76 + 77 + // check individual character groups 78 + const words = postText.split(REGEX.WORD_BOUNDARY) 79 + for (const word of words) { 80 + if (word === mutedWord) return true 81 + 82 + // compare word without leading/trailing punctuation, but allow internal 83 + // punctuation (such as `s@ssy`) 84 + const wordTrimmedPunctuation = word.replace( 85 + REGEX.LEADING_TRAILING_PUNCTUATION, 86 + '', 87 + ) 88 + 89 + if (mutedWord === wordTrimmedPunctuation) return true 90 + if (mutedWord.length > wordTrimmedPunctuation.length) continue 91 + 92 + if (/\p{P}+/u.test(wordTrimmedPunctuation)) { 93 + const spacedWord = wordTrimmedPunctuation.replace(/\p{P}+/gu, ' ') 94 + if (spacedWord === mutedWord) return true 95 + 96 + const contiguousWord = spacedWord.replace(/\s/gu, '') 97 + if (contiguousWord === mutedWord) return true 98 + 99 + const wordParts = wordTrimmedPunctuation.split(/\p{P}+/u) 100 + for (const wordPart of wordParts) { 101 + if (wordPart === mutedWord) return true 102 + } 103 + } 104 + } 105 + } 106 + 107 + return false 108 + }
+44
services/appview/src/moderation/subjects/account.ts
··· 1 + import { ModerationDecision } from '../decision' 2 + import { Label, ModerationOpts, ModerationSubjectProfile } from '../types' 3 + 4 + export function decideAccount( 5 + subject: ModerationSubjectProfile, 6 + opts: ModerationOpts, 7 + ): ModerationDecision { 8 + const acc = new ModerationDecision() 9 + 10 + acc.setDid(subject.did) 11 + acc.setIsMe(subject.did === opts.userDid) 12 + if (subject.viewer?.muted) { 13 + if (subject.viewer?.mutedByList) { 14 + acc.addMutedByList(subject.viewer?.mutedByList) 15 + } else { 16 + acc.addMuted(subject.viewer?.muted) 17 + } 18 + } 19 + if (subject.viewer?.blocking) { 20 + if (subject.viewer?.blockingByList) { 21 + acc.addBlockingByList(subject.viewer?.blockingByList) 22 + } else { 23 + acc.addBlocking(subject.viewer?.blocking) 24 + } 25 + } 26 + acc.addBlockedBy(subject.viewer?.blockedBy) 27 + 28 + for (const label of filterAccountLabels(subject.labels)) { 29 + acc.addLabel('account', label, opts) 30 + } 31 + 32 + return acc 33 + } 34 + 35 + export function filterAccountLabels(labels?: Label[]): Label[] { 36 + if (!labels) { 37 + return [] 38 + } 39 + return labels.filter( 40 + (label) => 41 + !label.uri.endsWith('/app.bsky.actor.profile/self') || 42 + label.val === '!no-unauthenticated', 43 + ) 44 + }
+24
services/appview/src/moderation/subjects/feed-generator.ts
··· 1 + import { ModerationDecision } from '../decision' 2 + import { ModerationOpts, ModerationSubjectFeedGenerator } from '../types' 3 + import { decideAccount } from './account' 4 + import { decideProfile } from './profile' 5 + 6 + export function decideFeedGenerator( 7 + subject: ModerationSubjectFeedGenerator, 8 + opts: ModerationOpts, 9 + ): ModerationDecision { 10 + const acc = new ModerationDecision() 11 + 12 + acc.setDid(subject.creator.did) 13 + acc.setIsMe(subject.creator.did === opts.userDid) 14 + if (subject.labels?.length) { 15 + for (const label of subject.labels) { 16 + acc.addLabel('content', label, opts) 17 + } 18 + } 19 + return ModerationDecision.merge( 20 + acc, 21 + decideAccount(subject.creator, opts), 22 + decideProfile(subject.creator, opts), 23 + ) 24 + }
+25
services/appview/src/moderation/subjects/notification.ts
··· 1 + import { ModerationDecision } from '../decision' 2 + import { ModerationOpts, ModerationSubjectNotification } from '../types' 3 + import { decideAccount } from './account' 4 + import { decideProfile } from './profile' 5 + 6 + export function decideNotification( 7 + subject: ModerationSubjectNotification, 8 + opts: ModerationOpts, 9 + ): ModerationDecision { 10 + const acc = new ModerationDecision() 11 + 12 + acc.setDid(subject.author.did) 13 + acc.setIsMe(subject.author.did === opts.userDid) 14 + if (subject.labels?.length) { 15 + for (const label of subject.labels) { 16 + acc.addLabel('content', label, opts) 17 + } 18 + } 19 + 20 + return ModerationDecision.merge( 21 + acc, 22 + decideAccount(subject.author, opts), 23 + decideProfile(subject.author, opts), 24 + ) 25 + }
+371
services/appview/src/moderation/subjects/post.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyEmbedExternal, 4 + AppBskyEmbedImages, 5 + AppBskyEmbedRecord, 6 + AppBskyEmbedRecordWithMedia, 7 + AppBskyFeedPost, 8 + } from '../../client' 9 + import { $Typed } from '../../client/util' 10 + import { ModerationDecision } from '../decision' 11 + import { hasMutedWord } from '../mutewords' 12 + import { ModerationOpts, ModerationSubjectPost } from '../types' 13 + import { decideAccount } from './account' 14 + import { decideProfile } from './profile' 15 + 16 + export function decidePost( 17 + subject: ModerationSubjectPost, 18 + opts: ModerationOpts, 19 + ): ModerationDecision { 20 + return ModerationDecision.merge( 21 + decideSubject(subject, opts), 22 + decideEmbed(subject.embed, opts)?.downgrade(), 23 + decideAccount(subject.author, opts), 24 + decideProfile(subject.author, opts), 25 + ) 26 + } 27 + 28 + function decideSubject( 29 + subject: ModerationSubjectPost, 30 + opts: ModerationOpts, 31 + ): ModerationDecision { 32 + const acc = new ModerationDecision() 33 + 34 + acc.setDid(subject.author.did) 35 + acc.setIsMe(subject.author.did === opts.userDid) 36 + if (subject.labels?.length) { 37 + for (const label of subject.labels) { 38 + acc.addLabel('content', label, opts) 39 + } 40 + } 41 + acc.addHidden(checkHiddenPost(subject, opts.prefs.hiddenPosts)) 42 + if (!acc.isMe) { 43 + acc.addMutedWord(checkMutedWords(subject, opts.prefs.mutedWords)) 44 + } 45 + 46 + return acc 47 + } 48 + 49 + function decideEmbed( 50 + embed: 51 + | undefined 52 + | $Typed<AppBskyEmbedRecord.View> 53 + | $Typed<AppBskyEmbedRecordWithMedia.View> 54 + | { $type: string }, 55 + opts: ModerationOpts, 56 + ) { 57 + if (embed) { 58 + if ( 59 + (AppBskyEmbedRecord.isView(embed) || 60 + AppBskyEmbedRecordWithMedia.isView(embed)) && 61 + AppBskyEmbedRecord.isViewRecord(embed.record) 62 + ) { 63 + // quote post 64 + return decideQuotedPost(embed.record, opts) 65 + } else if ( 66 + AppBskyEmbedRecordWithMedia.isView(embed) && 67 + AppBskyEmbedRecord.isViewRecord(embed.record.record) 68 + ) { 69 + // quoted post with media 70 + return decideQuotedPost(embed.record.record, opts) 71 + } else if ( 72 + (AppBskyEmbedRecord.isView(embed) || 73 + AppBskyEmbedRecordWithMedia.isView(embed)) && 74 + AppBskyEmbedRecord.isViewBlocked(embed.record) 75 + ) { 76 + // blocked quote post 77 + return decideBlockedQuotedPost(embed.record, opts) 78 + } else if ( 79 + AppBskyEmbedRecordWithMedia.isView(embed) && 80 + AppBskyEmbedRecord.isViewBlocked(embed.record.record) 81 + ) { 82 + // blocked quoted post with media 83 + return decideBlockedQuotedPost(embed.record.record, opts) 84 + } 85 + } 86 + 87 + return undefined 88 + } 89 + 90 + function decideQuotedPost( 91 + subject: AppBskyEmbedRecord.ViewRecord, 92 + opts: ModerationOpts, 93 + ) { 94 + const acc = new ModerationDecision() 95 + acc.setDid(subject.author.did) 96 + acc.setIsMe(subject.author.did === opts.userDid) 97 + if (subject.labels?.length) { 98 + for (const label of subject.labels) { 99 + acc.addLabel('content', label, opts) 100 + } 101 + } 102 + return ModerationDecision.merge( 103 + acc, 104 + decideAccount(subject.author, opts), 105 + decideProfile(subject.author, opts), 106 + ) 107 + } 108 + 109 + function decideBlockedQuotedPost( 110 + subject: AppBskyEmbedRecord.ViewBlocked, 111 + opts: ModerationOpts, 112 + ) { 113 + const acc = new ModerationDecision() 114 + acc.setDid(subject.author.did) 115 + acc.setIsMe(subject.author.did === opts.userDid) 116 + if (subject.author.viewer?.muted) { 117 + if (subject.author.viewer?.mutedByList) { 118 + acc.addMutedByList(subject.author.viewer?.mutedByList) 119 + } else { 120 + acc.addMuted(subject.author.viewer?.muted) 121 + } 122 + } 123 + if (subject.author.viewer?.blocking) { 124 + if (subject.author.viewer?.blockingByList) { 125 + acc.addBlockingByList(subject.author.viewer?.blockingByList) 126 + } else { 127 + acc.addBlocking(subject.author.viewer?.blocking) 128 + } 129 + } 130 + acc.addBlockedBy(subject.author.viewer?.blockedBy) 131 + return acc 132 + } 133 + 134 + function checkHiddenPost( 135 + subject: ModerationSubjectPost, 136 + hiddenPosts: string[] | undefined, 137 + ) { 138 + if (!hiddenPosts?.length) { 139 + return false 140 + } 141 + if (hiddenPosts.includes(subject.uri)) { 142 + return true 143 + } 144 + if (subject.embed) { 145 + if ( 146 + AppBskyEmbedRecord.isView(subject.embed) && 147 + AppBskyEmbedRecord.isViewRecord(subject.embed.record) && 148 + hiddenPosts.includes(subject.embed.record.uri) 149 + ) { 150 + return true 151 + } 152 + if ( 153 + AppBskyEmbedRecordWithMedia.isView(subject.embed) && 154 + AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) && 155 + hiddenPosts.includes(subject.embed.record.record.uri) 156 + ) { 157 + return true 158 + } 159 + } 160 + return false 161 + } 162 + 163 + function checkMutedWords( 164 + subject: ModerationSubjectPost, 165 + mutedWords: AppBskyActorDefs.MutedWord[] | undefined, 166 + ) { 167 + if (!mutedWords?.length) { 168 + return false 169 + } 170 + 171 + const postAuthor = subject.author 172 + 173 + if (AppBskyFeedPost.isRecord(subject.record)) { 174 + const post = subject.record as AppBskyFeedPost.Record 175 + // post text 176 + if ( 177 + hasMutedWord({ 178 + mutedWords, 179 + text: post.text, 180 + facets: post.facets, 181 + outlineTags: post.tags, 182 + languages: post.langs, 183 + actor: postAuthor, 184 + }) 185 + ) { 186 + return true 187 + } 188 + 189 + if (post.embed && AppBskyEmbedImages.isMain(post.embed)) { 190 + // post images 191 + for (const image of post.embed.images) { 192 + if ( 193 + hasMutedWord({ 194 + mutedWords, 195 + text: image.alt, 196 + languages: post.langs, 197 + actor: postAuthor, 198 + }) 199 + ) { 200 + return true 201 + } 202 + } 203 + } 204 + } 205 + 206 + const { embed } = subject 207 + if (embed) { 208 + // quote post 209 + if ( 210 + (AppBskyEmbedRecord.isView(embed) || 211 + AppBskyEmbedRecordWithMedia.isView(embed)) && 212 + AppBskyEmbedRecord.isViewRecord(embed.record) 213 + ) { 214 + if (AppBskyFeedPost.isRecord(embed.record.value)) { 215 + const embeddedPost = embed.record.value as AppBskyFeedPost.Record 216 + const embedAuthor = embed.record.author 217 + 218 + // quoted post text 219 + if ( 220 + hasMutedWord({ 221 + mutedWords, 222 + text: embeddedPost.text, 223 + facets: embeddedPost.facets, 224 + outlineTags: embeddedPost.tags, 225 + languages: embeddedPost.langs, 226 + actor: embedAuthor, 227 + }) 228 + ) { 229 + return true 230 + } 231 + 232 + // quoted post's images 233 + if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { 234 + for (const image of embeddedPost.embed.images) { 235 + if ( 236 + hasMutedWord({ 237 + mutedWords, 238 + text: image.alt, 239 + languages: embeddedPost.langs, 240 + actor: embedAuthor, 241 + }) 242 + ) { 243 + return true 244 + } 245 + } 246 + } 247 + 248 + // quoted post's link card 249 + if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) { 250 + const { external } = embeddedPost.embed 251 + if ( 252 + hasMutedWord({ 253 + mutedWords, 254 + text: external.title + ' ' + external.description, 255 + languages: [], 256 + actor: embedAuthor, 257 + }) 258 + ) { 259 + return true 260 + } 261 + } 262 + 263 + if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) { 264 + // quoted post's link card when it did a quote + media 265 + if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) { 266 + const { external } = embeddedPost.embed.media 267 + if ( 268 + hasMutedWord({ 269 + mutedWords, 270 + text: external.title + ' ' + external.description, 271 + languages: [], 272 + actor: embedAuthor, 273 + }) 274 + ) { 275 + return true 276 + } 277 + } 278 + 279 + // quoted post's images when it did a quote + media 280 + if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) { 281 + for (const image of embeddedPost.embed.media.images) { 282 + if ( 283 + hasMutedWord({ 284 + mutedWords, 285 + text: image.alt, 286 + languages: AppBskyFeedPost.isRecord(embeddedPost.record) 287 + ? embeddedPost.langs 288 + : [], 289 + actor: embedAuthor, 290 + }) 291 + ) { 292 + return true 293 + } 294 + } 295 + } 296 + } 297 + } 298 + } 299 + // link card 300 + else if (AppBskyEmbedExternal.isView(embed)) { 301 + const { external } = embed 302 + if ( 303 + hasMutedWord({ 304 + mutedWords, 305 + text: external.title + ' ' + external.description, 306 + languages: [], 307 + actor: postAuthor, 308 + }) 309 + ) { 310 + return true 311 + } 312 + } 313 + // quote post with media 314 + else if ( 315 + AppBskyEmbedRecordWithMedia.isView(embed) && 316 + AppBskyEmbedRecord.isViewRecord(embed.record.record) 317 + ) { 318 + const embedAuthor = embed.record.record.author 319 + 320 + // quoted post text 321 + if (AppBskyFeedPost.isRecord(embed.record.record.value)) { 322 + const post = embed.record.record.value as AppBskyFeedPost.Record 323 + if ( 324 + hasMutedWord({ 325 + mutedWords, 326 + text: post.text, 327 + facets: post.facets, 328 + outlineTags: post.tags, 329 + languages: post.langs, 330 + actor: embedAuthor, 331 + }) 332 + ) { 333 + return true 334 + } 335 + } 336 + 337 + // quoted post images 338 + if (AppBskyEmbedImages.isView(embed.media)) { 339 + for (const image of embed.media.images) { 340 + if ( 341 + hasMutedWord({ 342 + mutedWords, 343 + text: image.alt, 344 + languages: AppBskyFeedPost.isRecord(subject.record) 345 + ? (subject.record as AppBskyFeedPost.Record).langs 346 + : [], 347 + actor: embedAuthor, 348 + }) 349 + ) { 350 + return true 351 + } 352 + } 353 + } 354 + 355 + if (AppBskyEmbedExternal.isView(embed.media)) { 356 + const { external } = embed.media 357 + if ( 358 + hasMutedWord({ 359 + mutedWords, 360 + text: external.title + ' ' + external.description, 361 + languages: [], 362 + actor: embedAuthor, 363 + }) 364 + ) { 365 + return true 366 + } 367 + } 368 + } 369 + } 370 + return false 371 + }
+26
services/appview/src/moderation/subjects/profile.ts
··· 1 + import { ModerationDecision } from '../decision' 2 + import { Label, ModerationOpts, ModerationSubjectProfile } from '../types' 3 + 4 + export function decideProfile( 5 + subject: ModerationSubjectProfile, 6 + opts: ModerationOpts, 7 + ): ModerationDecision { 8 + const acc = new ModerationDecision() 9 + 10 + acc.setDid(subject.did) 11 + acc.setIsMe(subject.did === opts.userDid) 12 + for (const label of filterProfileLabels(subject.labels)) { 13 + acc.addLabel('profile', label, opts) 14 + } 15 + 16 + return acc 17 + } 18 + 19 + export function filterProfileLabels(labels?: Label[]): Label[] { 20 + if (!labels) { 21 + return [] 22 + } 23 + return labels.filter((label) => 24 + label.uri.endsWith('/app.bsky.actor.profile/self'), 25 + ) 26 + }
+48
services/appview/src/moderation/subjects/user-list.ts
··· 1 + import { AtUri } from '@atproto/syntax' 2 + import { AppBskyActorDefs } from '../../client/index' 3 + import { ModerationDecision } from '../decision' 4 + import { ModerationOpts, ModerationSubjectUserList } from '../types' 5 + import { decideAccount } from './account' 6 + import { decideProfile } from './profile' 7 + 8 + export function decideUserList( 9 + subject: ModerationSubjectUserList, 10 + opts: ModerationOpts, 11 + ): ModerationDecision { 12 + const acc = new ModerationDecision() 13 + 14 + const creator = 15 + // Note: ListViewBasic should not contain a creator field, but let's support it anyway 16 + 'creator' in subject && isProfile(subject.creator) 17 + ? subject.creator 18 + : undefined 19 + 20 + if (creator) { 21 + acc.setDid(creator.did) 22 + acc.setIsMe(creator.did === opts.userDid) 23 + if (subject.labels?.length) { 24 + for (const label of subject.labels) { 25 + acc.addLabel('content', label, opts) 26 + } 27 + } 28 + return ModerationDecision.merge( 29 + acc, 30 + decideAccount(creator, opts), 31 + decideProfile(creator, opts), 32 + ) 33 + } 34 + 35 + const creatorDid = new AtUri(subject.uri).hostname 36 + acc.setDid(creatorDid) 37 + acc.setIsMe(creatorDid === opts.userDid) 38 + if (subject.labels?.length) { 39 + for (const label of subject.labels) { 40 + acc.addLabel('content', label, opts) 41 + } 42 + } 43 + return acc 44 + } 45 + 46 + function isProfile(v: any): v is AppBskyActorDefs.ProfileViewBasic { 47 + return v && typeof v === 'object' && 'did' in v 48 + }
+189
services/appview/src/moderation/types.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyFeedDefs, 4 + AppBskyGraphDefs, 5 + AppBskyNotificationListNotifications, 6 + ChatBskyActorDefs, 7 + ComAtprotoLabelDefs, 8 + } from '../client/index' 9 + import { KnownLabelValue } from './const/labels' 10 + 11 + // syntax 12 + // = 13 + 14 + export const CUSTOM_LABEL_VALUE_RE = /^[a-z-]+$/ 15 + 16 + // behaviors 17 + // = 18 + 19 + export interface ModerationBehavior { 20 + profileList?: 'blur' | 'alert' | 'inform' 21 + profileView?: 'blur' | 'alert' | 'inform' 22 + avatar?: 'blur' | 'alert' 23 + banner?: 'blur' 24 + displayName?: 'blur' 25 + contentList?: 'blur' | 'alert' | 'inform' 26 + contentView?: 'blur' | 'alert' | 'inform' 27 + contentMedia?: 'blur' 28 + } 29 + export const BLOCK_BEHAVIOR: ModerationBehavior = { 30 + profileList: 'blur', 31 + profileView: 'alert', 32 + avatar: 'blur', 33 + banner: 'blur', 34 + contentList: 'blur', 35 + contentView: 'blur', 36 + } 37 + export const MUTE_BEHAVIOR: ModerationBehavior = { 38 + profileList: 'inform', 39 + profileView: 'alert', 40 + contentList: 'blur', 41 + contentView: 'inform', 42 + } 43 + export const MUTEWORD_BEHAVIOR: ModerationBehavior = { 44 + contentList: 'blur', 45 + contentView: 'blur', 46 + } 47 + export const HIDE_BEHAVIOR: ModerationBehavior = { 48 + contentList: 'blur', 49 + contentView: 'blur', 50 + } 51 + export const NOOP_BEHAVIOR: ModerationBehavior = {} 52 + 53 + // labels 54 + // = 55 + 56 + export type Label = ComAtprotoLabelDefs.Label 57 + export type LabelTarget = 'account' | 'profile' | 'content' 58 + export type LabelPreference = 'ignore' | 'warn' | 'hide' 59 + 60 + export type LabelValueDefinitionFlag = 61 + | 'no-override' 62 + | 'adult' 63 + | 'unauthed' 64 + | 'no-self' 65 + 66 + export interface InterpretedLabelValueDefinition 67 + extends ComAtprotoLabelDefs.LabelValueDefinition { 68 + definedBy?: string | undefined // did of labeler or undefined for global 69 + configurable: boolean 70 + defaultSetting: LabelPreference // type narrowing 71 + flags: LabelValueDefinitionFlag[] 72 + behaviors: { 73 + account?: ModerationBehavior 74 + profile?: ModerationBehavior 75 + content?: ModerationBehavior 76 + } 77 + } 78 + 79 + export type LabelDefinitionMap = Record< 80 + KnownLabelValue, 81 + InterpretedLabelValueDefinition 82 + > 83 + 84 + // subjects 85 + // = 86 + 87 + export type ModerationSubjectProfile = 88 + | AppBskyActorDefs.ProfileViewBasic 89 + | AppBskyActorDefs.ProfileView 90 + | AppBskyActorDefs.ProfileViewDetailed 91 + | ChatBskyActorDefs.ProfileViewBasic 92 + 93 + export type ModerationSubjectPost = AppBskyFeedDefs.PostView 94 + 95 + export type ModerationSubjectNotification = 96 + AppBskyNotificationListNotifications.Notification 97 + 98 + export type ModerationSubjectFeedGenerator = AppBskyFeedDefs.GeneratorView 99 + 100 + export type ModerationSubjectUserList = 101 + | AppBskyGraphDefs.ListViewBasic 102 + | AppBskyGraphDefs.ListView 103 + 104 + export type ModerationSubject = 105 + | ModerationSubjectProfile 106 + | ModerationSubjectPost 107 + | ModerationSubjectNotification 108 + | ModerationSubjectFeedGenerator 109 + | ModerationSubjectUserList 110 + 111 + // behaviors 112 + // = 113 + 114 + export type ModerationCauseSource = 115 + | { type: 'user' } 116 + | { type: 'list'; list: AppBskyGraphDefs.ListViewBasic } 117 + | { type: 'labeler'; did: string } 118 + 119 + export type ModerationCause = 120 + | { 121 + type: 'blocking' 122 + source: ModerationCauseSource 123 + priority: 3 124 + downgraded?: boolean 125 + } 126 + | { 127 + type: 'blocked-by' 128 + source: ModerationCauseSource 129 + priority: 4 130 + downgraded?: boolean 131 + } 132 + | { 133 + type: 'block-other' 134 + source: ModerationCauseSource 135 + priority: 4 136 + downgraded?: boolean 137 + } 138 + | { 139 + type: 'label' 140 + source: ModerationCauseSource 141 + label: Label 142 + labelDef: InterpretedLabelValueDefinition 143 + target: LabelTarget 144 + setting: LabelPreference 145 + behavior: ModerationBehavior 146 + noOverride: boolean 147 + priority: 1 | 2 | 5 | 7 | 8 148 + downgraded?: boolean 149 + } 150 + | { 151 + type: 'muted' 152 + source: ModerationCauseSource 153 + priority: 6 154 + downgraded?: boolean 155 + } 156 + | { 157 + type: 'mute-word' 158 + source: ModerationCauseSource 159 + priority: 6 160 + downgraded?: boolean 161 + } 162 + | { 163 + type: 'hidden' 164 + source: ModerationCauseSource 165 + priority: 6 166 + downgraded?: boolean 167 + } 168 + 169 + export interface ModerationPrefsLabeler { 170 + did: string 171 + labels: Record<string, LabelPreference> 172 + } 173 + 174 + export interface ModerationPrefs { 175 + adultContentEnabled: boolean 176 + labels: Record<string, LabelPreference> 177 + labelers: ModerationPrefsLabeler[] 178 + mutedWords: AppBskyActorDefs.MutedWord[] 179 + hiddenPosts: string[] 180 + } 181 + 182 + export interface ModerationOpts { 183 + userDid: string | undefined 184 + prefs: ModerationPrefs 185 + /** 186 + * Map of labeler did -> custom definitions 187 + */ 188 + labelDefs?: Record<string, InterpretedLabelValueDefinition[]> 189 + }
+21
services/appview/src/moderation/ui.ts
··· 1 + import { ModerationCause } from './types' 2 + 3 + export class ModerationUI { 4 + noOverride = false 5 + filters: ModerationCause[] = [] 6 + blurs: ModerationCause[] = [] 7 + alerts: ModerationCause[] = [] 8 + informs: ModerationCause[] = [] 9 + get filter(): boolean { 10 + return this.filters.length !== 0 11 + } 12 + get blur(): boolean { 13 + return this.blurs.length !== 0 14 + } 15 + get alert(): boolean { 16 + return this.alerts.length !== 0 17 + } 18 + get inform(): boolean { 19 + return this.informs.length !== 0 20 + } 21 + }
+111
services/appview/src/moderation/util.ts
··· 1 + import { 2 + AppBskyEmbedRecord, 3 + AppBskyEmbedRecordWithMedia, 4 + AppBskyLabelerDefs, 5 + ComAtprotoLabelDefs, 6 + } from '../client' 7 + import { asPredicate } from '../client/util' 8 + import { 9 + InterpretedLabelValueDefinition, 10 + LabelPreference, 11 + LabelValueDefinitionFlag, 12 + ModerationBehavior, 13 + } from './types' 14 + 15 + export function isQuotedPost(embed: unknown): embed is AppBskyEmbedRecord.View { 16 + return Boolean(embed && AppBskyEmbedRecord.isView(embed)) 17 + } 18 + 19 + export function isQuotedPostWithMedia( 20 + embed: unknown, 21 + ): embed is AppBskyEmbedRecordWithMedia.View { 22 + return Boolean(embed && AppBskyEmbedRecordWithMedia.isView(embed)) 23 + } 24 + 25 + export function interpretLabelValueDefinition( 26 + def: ComAtprotoLabelDefs.LabelValueDefinition, 27 + definedBy: string | undefined, 28 + ): InterpretedLabelValueDefinition { 29 + const behaviors: { 30 + account: ModerationBehavior 31 + profile: ModerationBehavior 32 + content: ModerationBehavior 33 + } = { 34 + account: {}, 35 + profile: {}, 36 + content: {}, 37 + } 38 + const alertOrInform: 'alert' | 'inform' | undefined = 39 + def.severity === 'alert' 40 + ? 'alert' 41 + : def.severity === 'inform' 42 + ? 'inform' 43 + : undefined 44 + if (def.blurs === 'content') { 45 + // target=account, blurs=content 46 + behaviors.account.profileList = alertOrInform 47 + behaviors.account.profileView = alertOrInform 48 + behaviors.account.contentList = 'blur' 49 + behaviors.account.contentView = def.adultOnly ? 'blur' : alertOrInform 50 + // target=profile, blurs=content 51 + behaviors.profile.profileList = alertOrInform 52 + behaviors.profile.profileView = alertOrInform 53 + // target=content, blurs=content 54 + behaviors.content.contentList = 'blur' 55 + behaviors.content.contentView = def.adultOnly ? 'blur' : alertOrInform 56 + } else if (def.blurs === 'media') { 57 + // target=account, blurs=media 58 + behaviors.account.profileList = alertOrInform 59 + behaviors.account.profileView = alertOrInform 60 + behaviors.account.avatar = 'blur' 61 + behaviors.account.banner = 'blur' 62 + // target=profile, blurs=media 63 + behaviors.profile.profileList = alertOrInform 64 + behaviors.profile.profileView = alertOrInform 65 + behaviors.profile.avatar = 'blur' 66 + behaviors.profile.banner = 'blur' 67 + // target=content, blurs=media 68 + behaviors.content.contentMedia = 'blur' 69 + } else if (def.blurs === 'none') { 70 + // target=account, blurs=none 71 + behaviors.account.profileList = alertOrInform 72 + behaviors.account.profileView = alertOrInform 73 + behaviors.account.contentList = alertOrInform 74 + behaviors.account.contentView = alertOrInform 75 + // target=profile, blurs=none 76 + behaviors.profile.profileList = alertOrInform 77 + behaviors.profile.profileView = alertOrInform 78 + // target=content, blurs=none 79 + behaviors.content.contentList = alertOrInform 80 + behaviors.content.contentView = alertOrInform 81 + } 82 + 83 + let defaultSetting: LabelPreference = 'warn' 84 + if (def.defaultSetting === 'hide' || def.defaultSetting === 'ignore') { 85 + defaultSetting = def.defaultSetting as LabelPreference 86 + } 87 + 88 + const flags: LabelValueDefinitionFlag[] = ['no-self'] 89 + if (def.adultOnly) { 90 + flags.push('adult') 91 + } 92 + 93 + return { 94 + ...def, 95 + definedBy, 96 + configurable: true, 97 + defaultSetting, 98 + flags, 99 + behaviors, 100 + } 101 + } 102 + 103 + export function interpretLabelValueDefinitions( 104 + labelerView: AppBskyLabelerDefs.LabelerViewDetailed, 105 + ): InterpretedLabelValueDefinition[] { 106 + return (labelerView.policies?.labelValueDefinitions || []) 107 + .filter(asPredicate(ComAtprotoLabelDefs.validateLabelValueDefinition)) 108 + .map((labelValDef) => 109 + interpretLabelValueDefinition(labelValDef, labelerView.creator.did), 110 + ) 111 + }