[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.

Fix build on appview

remove moderation stuff for now

Honys d26d7d26 b5242fe6

+10 -1892
-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 - ]
+5 -2
services/appview/package.json
··· 2 2 "name": "app", 3 3 "version": "1.0.50", 4 4 "scripts": { 5 - "codegen": "node ./scripts/generate-code.mjs && lex gen-api --yes ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/so/sprk/*/*", 5 + "codegen": "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 - "build": "tsc src/index.ts --outDir dist", 8 + "build": "tsc --build tsconfig.json", 9 9 "docker-dev": "docker compose -f compose.dev.yaml up --build --watch" 10 10 }, 11 11 "dependencies": { ··· 14 14 "@atproto/oauth-client-node": "^0.2.11", 15 15 "@atproto/sync": "^0.1.14", 16 16 "@atproto/syntax": "^0.3.3", 17 + "@atproto/common": "^0.4.8", 18 + "@atproto/api": "^0.14.6", 19 + "@atproto/xrpc": "^0.6.9", 17 20 "@atproto/xrpc-server": "^0.7.11", 18 21 "multiformats": "^9.9.0", 19 22 "dotenv": "^16.4.7",
-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 - }
-7
services/appview/src/validators/handle.ts
··· 1 - import { t } from "elysia"; 2 - 3 - export const ValidHandle = t.String({ 4 - minLength: 3, 5 - maxLength: 253, 6 - pattern: '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$' 7 - });
+5 -1
services/appview/tsconfig.json
··· 8 8 9 9 "strict": true /* Enable all strict type-checking options. */, 10 10 11 - "skipLibCheck": true /* Skip type checking all .d.ts files. */ 11 + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 12 + 13 + "rootDir": "./src", 14 + "outDir": "./dist", 15 + "noUnusedLocals": false, 12 16 } 13 17 }