Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Merge branch 'main' of github.com:bluesky-social/social-app into main

+1195 -763
+114
__e2e__/mock-server.ts
··· 63 63 }, 64 64 }) 65 65 } 66 + if ('labels' in url.query) { 67 + console.log('Generating naughty users with labels') 68 + 69 + const anchorPost = await server.mocker.createPost( 70 + 'alice', 71 + 'Anchor post', 72 + ) 73 + 74 + for (const user of [ 75 + 'csam-account', 76 + 'csam-profile', 77 + 'csam-posts', 78 + 'porn-account', 79 + 'porn-profile', 80 + 'porn-posts', 81 + 'nudity-account', 82 + 'nudity-profile', 83 + 'nudity-posts', 84 + 'muted-account', 85 + ]) { 86 + await server.mocker.createUser(user) 87 + await server.mocker.follow('alice', user) 88 + await server.mocker.follow(user, 'alice') 89 + await server.mocker.createPost(user, `Unlabeled post from ${user}`) 90 + await server.mocker.createReply( 91 + user, 92 + `Unlabeled reply from ${user}`, 93 + anchorPost, 94 + ) 95 + await server.mocker.like(user, anchorPost) 96 + } 97 + 98 + await server.mocker.labelAccount('csam', 'csam-account') 99 + await server.mocker.labelProfile('csam', 'csam-profile') 100 + await server.mocker.labelPost( 101 + 'csam', 102 + await server.mocker.createPost('csam-posts', 'csam post'), 103 + ) 104 + await server.mocker.labelPost( 105 + 'csam', 106 + await server.mocker.createQuotePost( 107 + 'csam-posts', 108 + 'csam quote post', 109 + anchorPost, 110 + ), 111 + ) 112 + await server.mocker.labelPost( 113 + 'csam', 114 + await server.mocker.createReply( 115 + 'csam-posts', 116 + 'csam reply', 117 + anchorPost, 118 + ), 119 + ) 120 + 121 + await server.mocker.labelAccount('porn', 'porn-account') 122 + await server.mocker.labelProfile('porn', 'porn-profile') 123 + await server.mocker.labelPost( 124 + 'porn', 125 + await server.mocker.createPost('porn-posts', 'porn post'), 126 + ) 127 + await server.mocker.labelPost( 128 + 'porn', 129 + await server.mocker.createQuotePost( 130 + 'porn-posts', 131 + 'porn quote post', 132 + anchorPost, 133 + ), 134 + ) 135 + await server.mocker.labelPost( 136 + 'porn', 137 + await server.mocker.createReply( 138 + 'porn-posts', 139 + 'porn reply', 140 + anchorPost, 141 + ), 142 + ) 143 + 144 + await server.mocker.labelAccount('nudity', 'nudity-account') 145 + await server.mocker.labelProfile('nudity', 'nudity-profile') 146 + await server.mocker.labelPost( 147 + 'nudity', 148 + await server.mocker.createPost('nudity-posts', 'nudity post'), 149 + ) 150 + await server.mocker.labelPost( 151 + 'nudity', 152 + await server.mocker.createQuotePost( 153 + 'nudity-posts', 154 + 'nudity quote post', 155 + anchorPost, 156 + ), 157 + ) 158 + await server.mocker.labelPost( 159 + 'nudity', 160 + await server.mocker.createReply( 161 + 'nudity-posts', 162 + 'nudity reply', 163 + anchorPost, 164 + ), 165 + ) 166 + 167 + await server.mocker.users.alice.agent.mute('muted-account.test') 168 + await server.mocker.createPost('muted-account', 'muted post') 169 + await server.mocker.createQuotePost( 170 + 'muted-account', 171 + 'account quote post', 172 + anchorPost, 173 + ) 174 + await server.mocker.createReply( 175 + 'muted-account', 176 + 'account reply', 177 + anchorPost, 178 + ) 179 + } 66 180 } 67 181 console.log('Ready') 68 182 return res.writeHead(200).end(server.pdsUrl)
+1 -1
app.json
··· 3 3 "name": "Bluesky", 4 4 "slug": "bluesky", 5 5 "owner": "blueskysocial", 6 - "version": "1.23.0", 6 + "version": "1.24.0", 7 7 "orientation": "portrait", 8 8 "icon": "./assets/icon.png", 9 9 "userInterfaceStyle": "light",
+147 -2
jest/test-pds.ts
··· 2 2 import os from 'os' 3 3 import net from 'net' 4 4 import path from 'path' 5 + import fs from 'fs' 5 6 import * as crypto from '@atproto/crypto' 6 7 import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds' 7 8 import * as plc from '@did-plc/lib' ··· 104 105 await pds.start() 105 106 const pdsUrl = `http://localhost:${port}` 106 107 108 + const profilePic = fs.readFileSync( 109 + path.join(__dirname, '..', 'assets', 'default-avatar.jpg'), 110 + ) 111 + 107 112 return { 108 113 pdsUrl, 109 - mocker: new Mocker(pdsUrl), 114 + mocker: new Mocker(pds, pdsUrl, profilePic), 110 115 async close() { 111 116 await pds.destroy() 112 117 await plcServer.destroy() ··· 118 123 agent: BskyAgent 119 124 users: Record<string, TestUser> = {} 120 125 121 - constructor(public service: string) { 126 + constructor( 127 + public pds: PDS, 128 + public service: string, 129 + public profilePic: Uint8Array, 130 + ) { 122 131 this.agent = new BskyAgent({service}) 123 132 } 124 133 ··· 152 161 handle: name + '.test', 153 162 password: 'hunter2', 154 163 }) 164 + await agent.upsertProfile(async () => { 165 + const blob = await agent.uploadBlob(this.profilePic, { 166 + encoding: 'image/jpeg', 167 + }) 168 + return { 169 + displayName: name, 170 + avatar: blob.data.blob, 171 + } 172 + }) 155 173 this.users[name] = { 156 174 did: res.data.did, 157 175 email, ··· 191 209 await this.follow('bob', 'carla') 192 210 await this.follow('carla', 'alice') 193 211 await this.follow('carla', 'bob') 212 + } 213 + 214 + async createPost(user: string, text: string) { 215 + const agent = this.users[user]?.agent 216 + if (!agent) { 217 + throw new Error(`Not a user: ${user}`) 218 + } 219 + return await agent.post({ 220 + text, 221 + createdAt: new Date().toISOString(), 222 + }) 223 + } 224 + 225 + async createQuotePost( 226 + user: string, 227 + text: string, 228 + {uri, cid}: {uri: string; cid: string}, 229 + ) { 230 + const agent = this.users[user]?.agent 231 + if (!agent) { 232 + throw new Error(`Not a user: ${user}`) 233 + } 234 + return await agent.post({ 235 + text, 236 + embed: {$type: 'app.bsky.embed.record', record: {uri, cid}}, 237 + createdAt: new Date().toISOString(), 238 + }) 239 + } 240 + 241 + async createReply( 242 + user: string, 243 + text: string, 244 + {uri, cid}: {uri: string; cid: string}, 245 + ) { 246 + const agent = this.users[user]?.agent 247 + if (!agent) { 248 + throw new Error(`Not a user: ${user}`) 249 + } 250 + return await agent.post({ 251 + text, 252 + reply: {root: {uri, cid}, parent: {uri, cid}}, 253 + createdAt: new Date().toISOString(), 254 + }) 255 + } 256 + 257 + async like(user: string, {uri, cid}: {uri: string; cid: string}) { 258 + const agent = this.users[user]?.agent 259 + if (!agent) { 260 + throw new Error(`Not a user: ${user}`) 261 + } 262 + return await agent.like(uri, cid) 263 + } 264 + 265 + async labelAccount(label: string, user: string) { 266 + const did = this.users[user]?.did 267 + if (!did) { 268 + throw new Error(`Invalid user: ${user}`) 269 + } 270 + const ctx = this.pds.ctx 271 + if (!ctx) { 272 + throw new Error('Invalid PDS') 273 + } 274 + 275 + await ctx.db.db 276 + .insertInto('label') 277 + .values([ 278 + { 279 + src: ctx.cfg.labelerDid, 280 + uri: did, 281 + cid: '', 282 + val: label, 283 + neg: 0, 284 + cts: new Date().toISOString(), 285 + }, 286 + ]) 287 + .execute() 288 + } 289 + 290 + async labelProfile(label: string, user: string) { 291 + const agent = this.users[user]?.agent 292 + const did = this.users[user]?.did 293 + if (!did) { 294 + throw new Error(`Invalid user: ${user}`) 295 + } 296 + 297 + const profile = await agent.app.bsky.actor.profile.get({ 298 + repo: user + '.test', 299 + rkey: 'self', 300 + }) 301 + 302 + const ctx = this.pds.ctx 303 + if (!ctx) { 304 + throw new Error('Invalid PDS') 305 + } 306 + await ctx.db.db 307 + .insertInto('label') 308 + .values([ 309 + { 310 + src: ctx.cfg.labelerDid, 311 + uri: profile.uri, 312 + cid: profile.cid, 313 + val: label, 314 + neg: 0, 315 + cts: new Date().toISOString(), 316 + }, 317 + ]) 318 + .execute() 319 + } 320 + 321 + async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) { 322 + const ctx = this.pds.ctx 323 + if (!ctx) { 324 + throw new Error('Invalid PDS') 325 + } 326 + await ctx.db.db 327 + .insertInto('label') 328 + .values([ 329 + { 330 + src: ctx.cfg.labelerDid, 331 + uri, 332 + cid, 333 + val: label, 334 + neg: 0, 335 + cts: new Date().toISOString(), 336 + }, 337 + ]) 338 + .execute() 194 339 } 195 340 } 196 341
+2 -2
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.23.0", 3 + "version": "1.24.0", 4 4 "private": true, 5 5 "scripts": { 6 6 "postinstall": "patch-package", ··· 22 22 "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" 23 23 }, 24 24 "dependencies": { 25 - "@atproto/api": "0.2.9", 25 + "@atproto/api": "0.2.10", 26 26 "@bam.tech/react-native-image-resizer": "^3.0.4", 27 27 "@braintree/sanitize-url": "^6.0.2", 28 28 "@expo/webpack-config": "^18.0.1",
+12 -8
src/lib/labeling/const.ts
··· 1 1 import {LabelPreferencesModel} from 'state/models/ui/preferences' 2 - 3 - export interface LabelValGroup { 4 - id: keyof LabelPreferencesModel | 'illegal' | 'unknown' 5 - title: string 6 - subtitle?: string 7 - warning?: string 8 - values: string[] 9 - } 2 + import {LabelValGroup} from './types' 10 3 11 4 export const ILLEGAL_LABEL_GROUP: LabelValGroup = { 12 5 id: 'illegal', 13 6 title: 'Illegal Content', 7 + warning: 'Illegal Content', 14 8 values: ['csam', 'dmca-violation', 'nudity-nonconsentual'], 9 + imagesOnly: false, // not applicable 15 10 } 16 11 17 12 export const UNKNOWN_LABEL_GROUP: LabelValGroup = { 18 13 id: 'unknown', 19 14 title: 'Unknown Label', 15 + warning: 'Content Warning', 20 16 values: [], 17 + imagesOnly: false, 21 18 } 22 19 23 20 export const CONFIGURABLE_LABEL_GROUPS: Record< ··· 30 27 subtitle: 'i.e. Pornography', 31 28 warning: 'Sexually Explicit', 32 29 values: ['porn'], 30 + imagesOnly: false, // apply to whole thing 33 31 }, 34 32 nudity: { 35 33 id: 'nudity', ··· 37 35 subtitle: 'Including non-sexual and artistic', 38 36 warning: 'Nudity', 39 37 values: ['nudity'], 38 + imagesOnly: true, 40 39 }, 41 40 suggestive: { 42 41 id: 'suggestive', ··· 44 43 subtitle: 'Does not include nudity', 45 44 warning: 'Sexually Suggestive', 46 45 values: ['sexual'], 46 + imagesOnly: true, 47 47 }, 48 48 gore: { 49 49 id: 'gore', ··· 51 51 subtitle: 'Gore, self-harm, torture', 52 52 warning: 'Violence', 53 53 values: ['gore', 'self-harm', 'torture'], 54 + imagesOnly: true, 54 55 }, 55 56 hate: { 56 57 id: 'hate', 57 58 title: 'Political Hate-Groups', 58 59 warning: 'Hate', 59 60 values: ['icon-kkk', 'icon-nazi'], 61 + imagesOnly: false, 60 62 }, 61 63 spam: { 62 64 id: 'spam', ··· 64 66 subtitle: 'Excessive low-quality posts', 65 67 warning: 'Spam', 66 68 values: ['spam'], 69 + imagesOnly: false, 67 70 }, 68 71 impersonation: { 69 72 id: 'impersonation', ··· 71 74 subtitle: 'Accounts falsely claiming to be people or orgs', 72 75 warning: 'Impersonation', 73 76 values: ['impersonation'], 77 + imagesOnly: false, 74 78 }, 75 79 }
+302 -1
src/lib/labeling/helpers.ts
··· 1 1 import { 2 - LabelValGroup, 2 + AppBskyActorDefs, 3 + AppBskyEmbedRecordWithMedia, 4 + AppBskyEmbedRecord, 5 + AppBskyFeedPost, 6 + AppBskyEmbedImages, 7 + AppBskyEmbedExternal, 8 + } from '@atproto/api' 9 + import { 3 10 CONFIGURABLE_LABEL_GROUPS, 4 11 ILLEGAL_LABEL_GROUP, 5 12 UNKNOWN_LABEL_GROUP, 6 13 } from './const' 14 + import { 15 + Label, 16 + LabelValGroup, 17 + ModerationBehaviorCode, 18 + PostModeration, 19 + ProfileModeration, 20 + PostLabelInfo, 21 + ProfileLabelInfo, 22 + } from './types' 23 + import {RootStoreModel} from 'state/index' 24 + 25 + type Embed = 26 + | AppBskyEmbedRecord.View 27 + | AppBskyEmbedImages.View 28 + | AppBskyEmbedExternal.View 29 + | AppBskyEmbedRecordWithMedia.View 30 + | {$type: string; [k: string]: unknown} 7 31 8 32 export function getLabelValueGroup(labelVal: string): LabelValGroup { 9 33 let id: keyof typeof CONFIGURABLE_LABEL_GROUPS ··· 17 41 } 18 42 return UNKNOWN_LABEL_GROUP 19 43 } 44 + 45 + export function getPostModeration( 46 + store: RootStoreModel, 47 + postInfo: PostLabelInfo, 48 + ): PostModeration { 49 + const accountPref = store.preferences.getLabelPreference( 50 + postInfo.accountLabels, 51 + ) 52 + const profilePref = store.preferences.getLabelPreference( 53 + postInfo.profileLabels, 54 + ) 55 + const postPref = store.preferences.getLabelPreference(postInfo.postLabels) 56 + 57 + // avatar 58 + let avatar = { 59 + warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', 60 + blur: 61 + accountPref.pref === 'hide' || 62 + accountPref.pref === 'warn' || 63 + profilePref.pref === 'hide' || 64 + profilePref.pref === 'warn', 65 + } 66 + 67 + // hide no-override cases 68 + if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') { 69 + return hidePostNoOverride(accountPref.desc.warning) 70 + } 71 + if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') { 72 + return hidePostNoOverride(profilePref.desc.warning) 73 + } 74 + if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') { 75 + return hidePostNoOverride(postPref.desc.warning) 76 + } 77 + 78 + // hide cases 79 + if (accountPref.pref === 'hide') { 80 + return { 81 + avatar, 82 + list: hide(accountPref.desc.warning), 83 + thread: hide(accountPref.desc.warning), 84 + view: warn(accountPref.desc.warning), 85 + } 86 + } 87 + if (profilePref.pref === 'hide') { 88 + return { 89 + avatar, 90 + list: hide(profilePref.desc.warning), 91 + thread: hide(profilePref.desc.warning), 92 + view: warn(profilePref.desc.warning), 93 + } 94 + } 95 + if (postPref.pref === 'hide') { 96 + return { 97 + avatar, 98 + list: hide(postPref.desc.warning), 99 + thread: hide(postPref.desc.warning), 100 + view: warn(postPref.desc.warning), 101 + } 102 + } 103 + 104 + // muting 105 + if (postInfo.isMuted) { 106 + return { 107 + avatar, 108 + list: hide('Post from an account you muted.'), 109 + thread: warn('Post from an account you muted.'), 110 + view: warn('Post from an account you muted.'), 111 + } 112 + } 113 + 114 + // warning cases 115 + if (postPref.pref === 'warn') { 116 + if (postPref.desc.imagesOnly) { 117 + return { 118 + avatar, 119 + list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time 120 + thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time 121 + view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time 122 + } 123 + } 124 + return { 125 + avatar, 126 + list: warnContent(postPref.desc.warning), 127 + thread: warnContent(postPref.desc.warning), 128 + view: warnContent(postPref.desc.warning), 129 + } 130 + } 131 + if (accountPref.pref === 'warn') { 132 + return { 133 + avatar, 134 + list: warnContent(accountPref.desc.warning), 135 + thread: warnContent(accountPref.desc.warning), 136 + view: warnContent(accountPref.desc.warning), 137 + } 138 + } 139 + 140 + return { 141 + avatar, 142 + list: show(), 143 + thread: show(), 144 + view: show(), 145 + } 146 + } 147 + 148 + export function getProfileModeration( 149 + store: RootStoreModel, 150 + profileLabels: ProfileLabelInfo, 151 + ): ProfileModeration { 152 + const accountPref = store.preferences.getLabelPreference( 153 + profileLabels.accountLabels, 154 + ) 155 + const profilePref = store.preferences.getLabelPreference( 156 + profileLabels.profileLabels, 157 + ) 158 + 159 + // avatar 160 + let avatar = { 161 + warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', 162 + blur: 163 + accountPref.pref === 'hide' || 164 + accountPref.pref === 'warn' || 165 + profilePref.pref === 'hide' || 166 + profilePref.pref === 'warn', 167 + } 168 + 169 + // hide no-override cases 170 + if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') { 171 + return hideProfileNoOverride(accountPref.desc.warning) 172 + } 173 + if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') { 174 + return hideProfileNoOverride(profilePref.desc.warning) 175 + } 176 + 177 + // hide cases 178 + if (accountPref.pref === 'hide') { 179 + return { 180 + avatar, 181 + list: hide(accountPref.desc.warning), 182 + view: hide(accountPref.desc.warning), 183 + } 184 + } 185 + if (profilePref.pref === 'hide') { 186 + return { 187 + avatar, 188 + list: hide(profilePref.desc.warning), 189 + view: hide(profilePref.desc.warning), 190 + } 191 + } 192 + 193 + // warn cases 194 + if (accountPref.pref === 'warn') { 195 + return { 196 + avatar, 197 + list: warn(accountPref.desc.warning), 198 + view: warn(accountPref.desc.warning), 199 + } 200 + } 201 + // we don't warn for this 202 + // if (profilePref.pref === 'warn') { 203 + // return { 204 + // avatar, 205 + // list: warn(profilePref.desc.warning), 206 + // view: warn(profilePref.desc.warning), 207 + // } 208 + // } 209 + 210 + return { 211 + avatar, 212 + list: show(), 213 + view: show(), 214 + } 215 + } 216 + 217 + export function getProfileViewBasicLabelInfo( 218 + profile: AppBskyActorDefs.ProfileViewBasic, 219 + ): ProfileLabelInfo { 220 + return { 221 + accountLabels: filterAccountLabels(profile.labels), 222 + profileLabels: filterProfileLabels(profile.labels), 223 + isMuted: profile.viewer?.muted || false, 224 + } 225 + } 226 + 227 + export function getEmbedLabels(embed?: Embed): Label[] { 228 + if (!embed) { 229 + return [] 230 + } 231 + if ( 232 + AppBskyEmbedRecordWithMedia.isView(embed) && 233 + AppBskyEmbedRecord.isViewRecord(embed.record.record) && 234 + AppBskyFeedPost.isRecord(embed.record.record.value) && 235 + AppBskyFeedPost.validateRecord(embed.record.record.value).success 236 + ) { 237 + return embed.record.record.labels || [] 238 + } 239 + return [] 240 + } 241 + 242 + export function filterAccountLabels(labels?: Label[]): Label[] { 243 + if (!labels) { 244 + return [] 245 + } 246 + return labels.filter( 247 + label => !label.uri.endsWith('/app.bsky.actor.profile/self'), 248 + ) 249 + } 250 + 251 + export function filterProfileLabels(labels?: Label[]): Label[] { 252 + if (!labels) { 253 + return [] 254 + } 255 + return labels.filter(label => 256 + label.uri.endsWith('/app.bsky.actor.profile/self'), 257 + ) 258 + } 259 + 260 + // internal methods 261 + // = 262 + 263 + function show() { 264 + return { 265 + behavior: ModerationBehaviorCode.Show, 266 + } 267 + } 268 + 269 + function hidePostNoOverride(reason: string) { 270 + return { 271 + avatar: {warn: true, blur: true}, 272 + list: hideNoOverride(reason), 273 + thread: hideNoOverride(reason), 274 + view: hideNoOverride(reason), 275 + } 276 + } 277 + 278 + function hideProfileNoOverride(reason: string) { 279 + return { 280 + avatar: {warn: true, blur: true}, 281 + list: hideNoOverride(reason), 282 + view: hideNoOverride(reason), 283 + } 284 + } 285 + 286 + function hideNoOverride(reason: string) { 287 + return { 288 + behavior: ModerationBehaviorCode.Hide, 289 + reason, 290 + noOverride: true, 291 + } 292 + } 293 + 294 + function hide(reason: string) { 295 + return { 296 + behavior: ModerationBehaviorCode.Hide, 297 + reason, 298 + } 299 + } 300 + 301 + function warn(reason: string) { 302 + return { 303 + behavior: ModerationBehaviorCode.Warn, 304 + reason, 305 + } 306 + } 307 + 308 + function warnContent(reason: string) { 309 + return { 310 + behavior: ModerationBehaviorCode.WarnContent, 311 + reason, 312 + } 313 + } 314 + 315 + function warnImages(reason: string) { 316 + return { 317 + behavior: ModerationBehaviorCode.WarnImages, 318 + reason, 319 + } 320 + }
+58
src/lib/labeling/types.ts
··· 1 + import {ComAtprotoLabelDefs} from '@atproto/api' 2 + import {LabelPreferencesModel} from 'state/models/ui/preferences' 3 + 4 + export type Label = ComAtprotoLabelDefs.Label 5 + 6 + export interface LabelValGroup { 7 + id: keyof LabelPreferencesModel | 'illegal' | 'unknown' 8 + title: string 9 + imagesOnly: boolean 10 + subtitle?: string 11 + warning: string 12 + values: string[] 13 + } 14 + 15 + export interface PostLabelInfo { 16 + postLabels: Label[] 17 + accountLabels: Label[] 18 + profileLabels: Label[] 19 + isMuted: boolean 20 + } 21 + 22 + export interface ProfileLabelInfo { 23 + accountLabels: Label[] 24 + profileLabels: Label[] 25 + isMuted: boolean 26 + } 27 + 28 + export enum ModerationBehaviorCode { 29 + Show, 30 + Hide, 31 + Warn, 32 + WarnContent, 33 + WarnImages, 34 + } 35 + 36 + export interface ModerationBehavior { 37 + behavior: ModerationBehaviorCode 38 + noOverride?: boolean 39 + reason?: string 40 + } 41 + 42 + export interface AvatarModeration { 43 + warn: boolean 44 + blur: boolean 45 + } 46 + 47 + export interface PostModeration { 48 + avatar: AvatarModeration 49 + list: ModerationBehavior 50 + thread: ModerationBehavior 51 + view: ModerationBehavior 52 + } 53 + 54 + export interface ProfileModeration { 55 + avatar: AvatarModeration 56 + list: ModerationBehavior 57 + view: ModerationBehavior 58 + }
+22
src/state/models/content/post-thread.ts
··· 10 10 import * as apilib from 'lib/api/index' 11 11 import {cleanError} from 'lib/strings/errors' 12 12 import {updateDataOptimistically} from 'lib/async/revertible' 13 + import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 14 + import { 15 + getEmbedLabels, 16 + filterAccountLabels, 17 + filterProfileLabels, 18 + getPostModeration, 19 + } from 'lib/labeling/helpers' 13 20 14 21 export class PostThreadItemModel { 15 22 // ui state ··· 44 51 45 52 get isThreadMuted() { 46 53 return this.rootStore.mutedThreads.uris.has(this.rootUri) 54 + } 55 + 56 + get labelInfo(): PostLabelInfo { 57 + return { 58 + postLabels: (this.post.labels || []).concat( 59 + getEmbedLabels(this.post.embed), 60 + ), 61 + accountLabels: filterAccountLabels(this.post.author.labels), 62 + profileLabels: filterProfileLabels(this.post.author.labels), 63 + isMuted: this.post.author.viewer?.muted || false, 64 + } 65 + } 66 + 67 + get moderation(): PostModeration { 68 + return getPostModeration(this.rootStore, this.labelInfo) 47 69 } 48 70 49 71 constructor(
-122
src/state/models/content/post.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import {AppBskyFeedPost as Post} from '@atproto/api' 3 - import {AtUri} from '@atproto/api' 4 - import {RootStoreModel} from '../root-store' 5 - import {cleanError} from 'lib/strings/errors' 6 - 7 - type RemoveIndex<T> = { 8 - [P in keyof T as string extends P 9 - ? never 10 - : number extends P 11 - ? never 12 - : P]: T[P] 13 - } 14 - export class PostModel implements RemoveIndex<Post.Record> { 15 - // state 16 - isLoading = false 17 - hasLoaded = false 18 - error = '' 19 - uri: string = '' 20 - 21 - // data 22 - text: string = '' 23 - entities?: Post.Entity[] 24 - reply?: Post.ReplyRef 25 - createdAt: string = '' 26 - 27 - constructor(public rootStore: RootStoreModel, uri: string) { 28 - makeAutoObservable( 29 - this, 30 - { 31 - rootStore: false, 32 - uri: false, 33 - }, 34 - {autoBind: true}, 35 - ) 36 - this.uri = uri 37 - } 38 - 39 - get hasContent() { 40 - return this.createdAt !== '' 41 - } 42 - 43 - get hasError() { 44 - return this.error !== '' 45 - } 46 - 47 - get isEmpty() { 48 - return this.hasLoaded && !this.hasContent 49 - } 50 - 51 - get rootUri(): string { 52 - if (this.reply?.root.uri) { 53 - return this.reply.root.uri 54 - } 55 - return this.uri 56 - } 57 - 58 - get isThreadMuted() { 59 - return this.rootStore.mutedThreads.uris.has(this.rootUri) 60 - } 61 - 62 - // public api 63 - // = 64 - 65 - async setup() { 66 - await this._load() 67 - } 68 - 69 - async toggleThreadMute() { 70 - if (this.isThreadMuted) { 71 - this.rootStore.mutedThreads.uris.delete(this.rootUri) 72 - } else { 73 - this.rootStore.mutedThreads.uris.add(this.rootUri) 74 - } 75 - } 76 - 77 - // state transitions 78 - // = 79 - 80 - _xLoading() { 81 - this.isLoading = true 82 - this.error = '' 83 - } 84 - 85 - _xIdle(err?: any) { 86 - this.isLoading = false 87 - this.hasLoaded = true 88 - this.error = cleanError(err) 89 - if (err) { 90 - this.rootStore.log.error('Failed to fetch post', err) 91 - } 92 - } 93 - 94 - // loader functions 95 - // = 96 - 97 - async _load() { 98 - this._xLoading() 99 - try { 100 - const urip = new AtUri(this.uri) 101 - const res = await this.rootStore.agent.getPost({ 102 - repo: urip.host, 103 - rkey: urip.rkey, 104 - }) 105 - // TODO 106 - // if (!res.valid) { 107 - // throw new Error(res.error) 108 - // } 109 - this._replaceAll(res.value) 110 - this._xIdle() 111 - } catch (e: any) { 112 - this._xIdle(e) 113 - } 114 - } 115 - 116 - _replaceAll(res: Post.Record) { 117 - this.text = res.text 118 - this.entities = res.entities 119 - this.reply = res.reply 120 - this.createdAt = res.createdAt 121 - } 122 - }
+18
src/state/models/content/profile.ts
··· 10 10 import {cleanError} from 'lib/strings/errors' 11 11 import {FollowState} from '../cache/my-follows' 12 12 import {Image as RNImage} from 'react-native-image-crop-picker' 13 + import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types' 14 + import { 15 + getProfileModeration, 16 + filterAccountLabels, 17 + filterProfileLabels, 18 + } from 'lib/labeling/helpers' 13 19 14 20 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' 15 21 ··· 73 79 74 80 get isEmpty() { 75 81 return this.hasLoaded && !this.hasContent 82 + } 83 + 84 + get labelInfo(): ProfileLabelInfo { 85 + return { 86 + accountLabels: filterAccountLabels(this.labels), 87 + profileLabels: filterProfileLabels(this.labels), 88 + isMuted: this.viewer?.muted || false, 89 + } 90 + } 91 + 92 + get moderation(): ProfileModeration { 93 + return getProfileModeration(this.rootStore, this.labelInfo) 76 94 } 77 95 78 96 // public api
-88
src/state/models/discovery/suggested-posts.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {RootStoreModel} from '../root-store' 3 - import {PostsFeedItemModel} from '../feeds/posts' 4 - import {cleanError} from 'lib/strings/errors' 5 - import {TEAM_HANDLES} from 'lib/constants' 6 - import { 7 - getMultipleAuthorsPosts, 8 - mergePosts, 9 - } from 'lib/api/build-suggested-posts' 10 - 11 - export class SuggestedPostsModel { 12 - // state 13 - isLoading = false 14 - hasLoaded = false 15 - error = '' 16 - 17 - // data 18 - posts: PostsFeedItemModel[] = [] 19 - 20 - constructor(public rootStore: RootStoreModel) { 21 - makeAutoObservable( 22 - this, 23 - { 24 - rootStore: false, 25 - }, 26 - {autoBind: true}, 27 - ) 28 - } 29 - 30 - get hasContent() { 31 - return this.posts.length > 0 32 - } 33 - 34 - get hasError() { 35 - return this.error !== '' 36 - } 37 - 38 - get isEmpty() { 39 - return this.hasLoaded && !this.hasContent 40 - } 41 - 42 - // public api 43 - // = 44 - 45 - async setup() { 46 - this._xLoading() 47 - try { 48 - const responses = await getMultipleAuthorsPosts( 49 - this.rootStore, 50 - TEAM_HANDLES(String(this.rootStore.agent.service)), 51 - undefined, 52 - 30, 53 - ) 54 - runInAction(() => { 55 - const finalPosts = mergePosts(responses, {repostsOnly: true}) 56 - // hydrate into models 57 - this.posts = finalPosts.map((post, i) => { 58 - // strip the reasons to hide that these are reposts 59 - delete post.reason 60 - return new PostsFeedItemModel(this.rootStore, `post-${i}`, post) 61 - }) 62 - }) 63 - this._xIdle() 64 - } catch (e: any) { 65 - this.rootStore.log.error('SuggestedPostsView: Failed to load posts', { 66 - e, 67 - }) 68 - this._xIdle() // dont bubble to the user 69 - } 70 - } 71 - 72 - // state transitions 73 - // = 74 - 75 - _xLoading() { 76 - this.isLoading = true 77 - this.error = '' 78 - } 79 - 80 - _xIdle(err?: any) { 81 - this.isLoading = false 82 - this.hasLoaded = true 83 - this.error = cleanError(err) 84 - if (err) { 85 - this.rootStore.log.error('Failed to fetch suggested posts', err) 86 - } 87 - } 88 - }
+44 -10
src/state/models/feeds/notifications.ts
··· 15 15 import {RootStoreModel} from '../root-store' 16 16 import {PostThreadModel} from '../content/post-thread' 17 17 import {cleanError} from 'lib/strings/errors' 18 + import { 19 + PostLabelInfo, 20 + PostModeration, 21 + ModerationBehaviorCode, 22 + } from 'lib/labeling/types' 23 + import { 24 + getPostModeration, 25 + filterAccountLabels, 26 + filterProfileLabels, 27 + } from 'lib/labeling/helpers' 18 28 19 29 const GROUPABLE_REASONS = ['like', 'repost', 'follow'] 20 30 const PAGE_SIZE = 30 ··· 88 98 } else if (!preserve) { 89 99 this.additional = undefined 90 100 } 101 + } 102 + 103 + get labelInfo(): PostLabelInfo { 104 + const addedInfo = this.additionalPost?.thread?.labelInfo 105 + return { 106 + postLabels: (this.labels || []).concat(addedInfo?.postLabels || []), 107 + accountLabels: filterAccountLabels(this.author.labels).concat( 108 + addedInfo?.accountLabels || [], 109 + ), 110 + profileLabels: filterProfileLabels(this.author.labels).concat( 111 + addedInfo?.profileLabels || [], 112 + ), 113 + isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, 114 + } 115 + } 116 + 117 + get moderation(): PostModeration { 118 + return getPostModeration(this.rootStore, this.labelInfo) 91 119 } 92 120 93 121 get numUnreadInGroup(): number { ··· 520 548 _filterNotifications( 521 549 items: NotificationsFeedItemModel[], 522 550 ): NotificationsFeedItemModel[] { 523 - return items.filter(item => { 524 - const hideByLabel = 525 - this.rootStore.preferences.getLabelPreference(item.labels).pref === 526 - 'hide' 527 - let mutedThread = !!( 528 - item.reasonSubjectRootUri && 529 - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) 530 - ) 531 - return !hideByLabel && !mutedThread 532 - }) 551 + return items 552 + .filter(item => { 553 + const hideByLabel = 554 + item.moderation.list.behavior === ModerationBehaviorCode.Hide 555 + let mutedThread = !!( 556 + item.reasonSubjectRootUri && 557 + this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) 558 + ) 559 + return !hideByLabel && !mutedThread 560 + }) 561 + .map(item => { 562 + if (item.additional?.length) { 563 + item.additional = this._filterNotifications(item.additional) 564 + } 565 + return item 566 + }) 533 567 } 534 568 535 569 async _fetchItemModels(
+22
src/state/models/feeds/posts.ts
··· 20 20 } from 'lib/api/build-suggested-posts' 21 21 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 22 22 import {updateDataOptimistically} from 'lib/async/revertible' 23 + import {PostLabelInfo, PostModeration} from 'lib/labeling/types' 24 + import { 25 + getEmbedLabels, 26 + getPostModeration, 27 + filterAccountLabels, 28 + filterProfileLabels, 29 + } from 'lib/labeling/helpers' 23 30 24 31 type FeedViewPost = AppBskyFeedDefs.FeedViewPost 25 32 type ReasonRepost = AppBskyFeedDefs.ReasonRepost ··· 81 88 82 89 get isThreadMuted() { 83 90 return this.rootStore.mutedThreads.uris.has(this.rootUri) 91 + } 92 + 93 + get labelInfo(): PostLabelInfo { 94 + return { 95 + postLabels: (this.post.labels || []).concat( 96 + getEmbedLabels(this.post.embed), 97 + ), 98 + accountLabels: filterAccountLabels(this.post.author.labels), 99 + profileLabels: filterProfileLabels(this.post.author.labels), 100 + isMuted: this.post.author.viewer?.muted || false, 101 + } 102 + } 103 + 104 + get moderation(): PostModeration { 105 + return getPostModeration(this.rootStore, this.labelInfo) 84 106 } 85 107 86 108 copy(v: FeedViewPost) {
-66
src/view/com/discover/SuggestedPosts.tsx
··· 1 - import React from 'react' 2 - import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {useStores} from 'state/index' 5 - import {SuggestedPostsModel} from 'state/models/discovery/suggested-posts' 6 - import {s} from 'lib/styles' 7 - import {FeedItem as Post} from '../posts/FeedItem' 8 - import {Text} from '../util/text/Text' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - 11 - export const SuggestedPosts = observer(() => { 12 - const pal = usePalette('default') 13 - const store = useStores() 14 - const suggestedPostsView = React.useMemo<SuggestedPostsModel>( 15 - () => new SuggestedPostsModel(store), 16 - [store], 17 - ) 18 - 19 - React.useEffect(() => { 20 - if (!suggestedPostsView.hasLoaded) { 21 - suggestedPostsView.setup() 22 - } 23 - }, [store, suggestedPostsView]) 24 - 25 - return ( 26 - <> 27 - {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( 28 - <Text type="title" style={[styles.heading, pal.text]}> 29 - Recently, on Bluesky... 30 - </Text> 31 - )} 32 - {suggestedPostsView.hasContent && ( 33 - <> 34 - <View style={[pal.border, styles.bottomBorder]}> 35 - {suggestedPostsView.posts.map(item => ( 36 - <Post item={item} key={item._reactKey} showFollowBtn /> 37 - ))} 38 - </View> 39 - </> 40 - )} 41 - {suggestedPostsView.isLoading && ( 42 - <View style={s.mt10}> 43 - <ActivityIndicator /> 44 - </View> 45 - )} 46 - </> 47 - ) 48 - }) 49 - 50 - const styles = StyleSheet.create({ 51 - heading: { 52 - fontWeight: 'bold', 53 - paddingHorizontal: 12, 54 - paddingTop: 16, 55 - paddingBottom: 8, 56 - }, 57 - 58 - bottomBorder: { 59 - borderBottomWidth: 1, 60 - }, 61 - 62 - loadMore: { 63 - paddingLeft: 12, 64 - paddingVertical: 10, 65 - }, 66 - })
+1 -1
src/view/com/modals/InviteCodes.tsx
··· 57 57 code works once! 58 58 </Text> 59 59 <Text type="sm" style={[styles.description, pal.textLight]}> 60 - ( We'll send you more periodically. ) 60 + ( You'll receive one invite code every two weeks. ) 61 61 </Text> 62 62 <ScrollView style={[styles.scrollContainer, pal.border]}> 63 63 {store.me.invites.map((invite, i) => (
+30 -25
src/view/com/notifications/FeedItem.tsx
··· 8 8 View, 9 9 } from 'react-native' 10 10 import {AppBskyEmbedImages} from '@atproto/api' 11 - import {AtUri, ComAtprotoLabelDefs} from '@atproto/api' 11 + import {AtUri} from '@atproto/api' 12 12 import { 13 13 FontAwesomeIcon, 14 14 FontAwesomeIconStyle, ··· 26 26 import {ImageHorzList} from '../util/images/ImageHorzList' 27 27 import {Post} from '../post/Post' 28 28 import {Link, TextLink} from '../util/Link' 29 + import {useStores} from 'state/index' 29 30 import {usePalette} from 'lib/hooks/usePalette' 30 31 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 32 + import { 33 + getProfileViewBasicLabelInfo, 34 + getProfileModeration, 35 + } from 'lib/labeling/helpers' 36 + import {ProfileModeration} from 'lib/labeling/types' 31 37 32 38 const MAX_AUTHORS = 5 33 39 ··· 38 44 handle: string 39 45 displayName?: string 40 46 avatar?: string 41 - labels?: ComAtprotoLabelDefs.Label[] 47 + moderation: ProfileModeration 42 48 } 43 49 44 - export const FeedItem = observer(function FeedItem({ 50 + export const FeedItem = observer(function ({ 45 51 item, 46 52 }: { 47 53 item: NotificationsFeedItemModel 48 54 }) { 55 + const store = useStores() 49 56 const pal = usePalette('default') 50 57 const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) 51 58 const itemHref = useMemo(() => { ··· 81 88 handle: item.author.handle, 82 89 displayName: item.author.displayName, 83 90 avatar: item.author.avatar, 84 - labels: item.author.labels, 91 + moderation: getProfileModeration( 92 + store, 93 + getProfileViewBasicLabelInfo(item.author), 94 + ), 85 95 }, 86 - ...(item.additional?.map( 87 - ({author: {avatar, labels, handle, displayName}}) => { 88 - return { 89 - href: `/profile/${handle}`, 90 - handle, 91 - displayName, 92 - avatar, 93 - labels, 94 - } 95 - }, 96 - ) || []), 96 + ...(item.additional?.map(({author}) => { 97 + return { 98 + href: `/profile/${author.handle}`, 99 + handle: author.handle, 100 + displayName: author.displayName, 101 + avatar: author.avatar, 102 + moderation: getProfileModeration( 103 + store, 104 + getProfileViewBasicLabelInfo(author), 105 + ), 106 + } 107 + }) || []), 97 108 ] 98 - }, [ 99 - item.additional, 100 - item.author.avatar, 101 - item.author.displayName, 102 - item.author.handle, 103 - item.author.labels, 104 - ]) 109 + }, [store, item.additional, item.author]) 105 110 106 111 if (item.additionalPost?.notFound) { 107 112 // don't render anything if the target post was deleted or unfindable ··· 264 269 <UserAvatar 265 270 size={35} 266 271 avatar={authors[0].avatar} 267 - hasWarning={!!authors[0].labels?.length} 272 + moderation={authors[0].moderation.avatar} 268 273 /> 269 274 </Link> 270 275 </View> ··· 277 282 <UserAvatar 278 283 size={35} 279 284 avatar={author.avatar} 280 - hasWarning={!!author.labels?.length} 285 + moderation={author.moderation.avatar} 281 286 /> 282 287 </View> 283 288 ))} ··· 335 340 <UserAvatar 336 341 size={35} 337 342 avatar={author.avatar} 338 - hasWarning={!!author.labels?.length} 343 + moderation={author.moderation.avatar} 339 344 /> 340 345 </View> 341 346 <View style={s.flex1}>
+1 -9
src/view/com/post-thread/PostLikedBy.tsx
··· 47 47 // loaded 48 48 // = 49 49 const renderItem = ({item}: {item: LikeItem}) => ( 50 - <ProfileCardWithFollowBtn 51 - key={item.actor.did} 52 - did={item.actor.did} 53 - handle={item.actor.handle} 54 - displayName={item.actor.displayName} 55 - avatar={item.actor.avatar} 56 - labels={item.actor.labels} 57 - isFollowedBy={!!item.actor.viewer?.followedBy} 58 - /> 50 + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> 59 51 ) 60 52 return ( 61 53 <FlatList
+1 -9
src/view/com/post-thread/PostRepostedBy.tsx
··· 58 58 // loaded 59 59 // = 60 60 const renderItem = ({item}: {item: RepostedByItem}) => ( 61 - <ProfileCardWithFollowBtn 62 - key={item.did} 63 - did={item.did} 64 - handle={item.handle} 65 - displayName={item.displayName} 66 - avatar={item.avatar} 67 - labels={item.labels} 68 - isFollowedBy={!!item.viewer?.followedBy} 69 - /> 61 + <ProfileCardWithFollowBtn key={item.did} profile={item} /> 70 62 ) 71 63 return ( 72 64 <FlatList
+9 -16
src/view/com/post-thread/PostThreadItem.tsx
··· 145 145 146 146 if (item._isHighlightedPost) { 147 147 return ( 148 - <View 148 + <PostHider 149 149 testID={`postThreadItem-by-${item.post.author.handle}`} 150 - style={[ 151 - styles.outer, 152 - styles.outerHighlighted, 153 - {borderTopColor: pal.colors.border}, 154 - pal.view, 155 - ]}> 150 + style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} 151 + moderation={item.moderation.thread}> 156 152 <View style={styles.layout}> 157 153 <View style={styles.layoutAvi}> 158 154 <Link href={authorHref} title={authorTitle} asAnchor> 159 155 <UserAvatar 160 156 size={52} 161 157 avatar={item.post.author.avatar} 162 - hasWarning={!!item.post.author.labels?.length} 158 + moderation={item.moderation.avatar} 163 159 /> 164 160 </Link> 165 161 </View> ··· 218 214 </View> 219 215 </View> 220 216 <View style={[s.pl10, s.pr10, s.pb10]}> 221 - <ContentHider 222 - isMuted={item.post.author.viewer?.muted === true} 223 - labels={item.post.labels}> 217 + <ContentHider moderation={item.moderation.view}> 224 218 {item.richText?.text ? ( 225 219 <View 226 220 style={[ ··· 300 294 /> 301 295 </View> 302 296 </View> 303 - </View> 297 + </PostHider> 304 298 ) 305 299 } else { 306 300 return ( ··· 309 303 testID={`postThreadItem-by-${item.post.author.handle}`} 310 304 href={itemHref} 311 305 style={[styles.outer, {borderColor: pal.colors.border}, pal.view]} 312 - isMuted={item.post.author.viewer?.muted === true} 313 - labels={item.post.labels}> 306 + moderation={item.moderation.thread}> 314 307 {item._showParentReplyLine && ( 315 308 <View 316 309 style={[ ··· 333 326 <UserAvatar 334 327 size={52} 335 328 avatar={item.post.author.avatar} 336 - hasWarning={!!item.post.author.labels?.length} 329 + moderation={item.moderation.avatar} 337 330 /> 338 331 </Link> 339 332 </View> ··· 347 340 did={item.post.author.did} 348 341 /> 349 342 <ContentHider 350 - labels={item.post.labels} 343 + moderation={item.moderation.thread} 351 344 containerStyle={styles.contentHider}> 352 345 {item.richText?.text ? ( 353 346 <View style={styles.postTextContainer}>
+3 -4
src/view/com/post/Post.tsx
··· 206 206 <PostHider 207 207 href={itemHref} 208 208 style={[styles.outer, pal.view, pal.border, style]} 209 - isMuted={item.post.author.viewer?.muted === true} 210 - labels={item.post.labels}> 209 + moderation={item.moderation.list}> 211 210 {showReplyLine && <View style={styles.replyLine} />} 212 211 <View style={styles.layout}> 213 212 <View style={styles.layoutAvi}> ··· 215 214 <UserAvatar 216 215 size={52} 217 216 avatar={item.post.author.avatar} 218 - hasWarning={!!item.post.author.labels?.length} 217 + moderation={item.moderation.avatar} 219 218 /> 220 219 </Link> 221 220 </View> ··· 247 246 </View> 248 247 )} 249 248 <ContentHider 250 - labels={item.post.labels} 249 + moderation={item.moderation.list} 251 250 containerStyle={styles.contentHider}> 252 251 {item.richText?.text ? ( 253 252 <View style={styles.postTextContainer}>
-62
src/view/com/post/PostText.tsx
··· 1 - import React, {useState, useEffect} from 'react' 2 - import {observer} from 'mobx-react-lite' 3 - import {StyleProp, StyleSheet, TextStyle, View} from 'react-native' 4 - import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 5 - import {ErrorMessage} from '../util/error/ErrorMessage' 6 - import {Text} from '../util/text/Text' 7 - import {PostModel} from 'state/models/content/post' 8 - import {useStores} from 'state/index' 9 - 10 - export const PostText = observer(function PostText({ 11 - uri, 12 - style, 13 - }: { 14 - uri: string 15 - style?: StyleProp<TextStyle> 16 - }) { 17 - const store = useStores() 18 - const [model, setModel] = useState<PostModel | undefined>() 19 - 20 - useEffect(() => { 21 - if (model?.uri === uri) { 22 - return // no change needed? or trigger refresh? 23 - } 24 - const newModel = new PostModel(store, uri) 25 - setModel(newModel) 26 - newModel.setup().catch(err => store.log.error('Failed to fetch post', err)) 27 - }, [uri, model?.uri, store]) 28 - 29 - // loading 30 - // = 31 - if (!model || model.isLoading || model.uri !== uri) { 32 - return ( 33 - <View> 34 - <LoadingPlaceholder width="100%" height={8} style={styles.mt6} /> 35 - <LoadingPlaceholder width="100%" height={8} style={styles.mt6} /> 36 - <LoadingPlaceholder width={100} height={8} style={styles.mt6} /> 37 - </View> 38 - ) 39 - } 40 - 41 - // error 42 - // = 43 - if (model.hasError) { 44 - return ( 45 - <View> 46 - <ErrorMessage style={style} message={model.error} /> 47 - </View> 48 - ) 49 - } 50 - 51 - // loaded 52 - // = 53 - return ( 54 - <View> 55 - <Text style={style}>{model.text}</Text> 56 - </View> 57 - ) 58 - }) 59 - 60 - const styles = StyleSheet.create({ 61 - mt6: {marginTop: 6}, 62 - })
+4 -8
src/view/com/posts/FeedItem.tsx
··· 30 30 isThreadChild, 31 31 isThreadParent, 32 32 showFollowBtn, 33 - ignoreMuteFor, 34 33 }: { 35 34 item: PostsFeedItemModel 36 35 isThreadChild?: boolean 37 36 isThreadParent?: boolean 38 37 showReplyLine?: boolean 39 38 showFollowBtn?: boolean 40 - ignoreMuteFor?: string 39 + ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf 41 40 }) { 42 41 const store = useStores() 43 42 const pal = usePalette('default') ··· 134 133 } 135 134 136 135 const isSmallTop = isThreadChild 137 - const isMuted = 138 - item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did 139 136 const outerStyles = [ 140 137 styles.outer, 141 138 pal.view, ··· 149 146 testID={`feedItem-by-${item.post.author.handle}`} 150 147 style={outerStyles} 151 148 href={itemHref} 152 - isMuted={isMuted} 153 - labels={item.post.labels}> 149 + moderation={item.moderation.list}> 154 150 {isThreadChild && ( 155 151 <View 156 152 style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} ··· 200 196 <UserAvatar 201 197 size={52} 202 198 avatar={item.post.author.avatar} 203 - hasWarning={!!item.post.author.labels?.length} 199 + moderation={item.moderation.avatar} 204 200 /> 205 201 </Link> 206 202 </View> ··· 236 232 </View> 237 233 )} 238 234 <ContentHider 239 - labels={item.post.labels} 235 + moderation={item.moderation.list} 240 236 containerStyle={styles.contentHider}> 241 237 {item.richText?.text ? ( 242 238 <View style={styles.postTextContainer}>
+131 -115
src/view/com/profile/ProfileCard.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 - import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api' 4 + import {AppBskyActorDefs} from '@atproto/api' 5 5 import {Link} from '../util/Link' 6 6 import {Text} from '../util/text/Text' 7 7 import {UserAvatar} from '../util/UserAvatar' ··· 10 10 import {useStores} from 'state/index' 11 11 import {FollowButton} from './FollowButton' 12 12 import {sanitizeDisplayName} from 'lib/strings/display-names' 13 + import { 14 + getProfileViewBasicLabelInfo, 15 + getProfileModeration, 16 + } from 'lib/labeling/helpers' 17 + import {ModerationBehaviorCode} from 'lib/labeling/types' 18 + 19 + export const ProfileCard = observer( 20 + ({ 21 + testID, 22 + profile, 23 + noBg, 24 + noBorder, 25 + followers, 26 + renderButton, 27 + }: { 28 + testID?: string 29 + profile: AppBskyActorDefs.ProfileViewBasic 30 + noBg?: boolean 31 + noBorder?: boolean 32 + followers?: AppBskyActorDefs.ProfileView[] | undefined 33 + renderButton?: () => JSX.Element 34 + }) => { 35 + const store = useStores() 36 + const pal = usePalette('default') 37 + 38 + const moderation = getProfileModeration( 39 + store, 40 + getProfileViewBasicLabelInfo(profile), 41 + ) 13 42 14 - export function ProfileCard({ 15 - testID, 16 - handle, 17 - displayName, 18 - avatar, 19 - description, 20 - labels, 21 - isFollowedBy, 22 - noBg, 23 - noBorder, 24 - followers, 25 - renderButton, 26 - }: { 27 - testID?: string 28 - handle: string 29 - displayName?: string 30 - avatar?: string 31 - description?: string 32 - labels: ComAtprotoLabelDefs.Label[] | undefined 33 - isFollowedBy?: boolean 34 - noBg?: boolean 35 - noBorder?: boolean 36 - followers?: AppBskyActorDefs.ProfileView[] | undefined 37 - renderButton?: () => JSX.Element 38 - }) { 39 - const pal = usePalette('default') 40 - return ( 41 - <Link 42 - testID={testID} 43 - style={[ 44 - styles.outer, 45 - pal.border, 46 - noBorder && styles.outerNoBorder, 47 - !noBg && pal.view, 48 - ]} 49 - href={`/profile/${handle}`} 50 - title={handle} 51 - asAnchor> 52 - <View style={styles.layout}> 53 - <View style={styles.layoutAvi}> 54 - <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} /> 55 - </View> 56 - <View style={styles.layoutContent}> 57 - <Text 58 - type="lg" 59 - style={[s.bold, pal.text]} 60 - numberOfLines={1} 61 - lineHeight={1.2}> 62 - {sanitizeDisplayName(displayName || handle)} 63 - </Text> 64 - <Text type="md" style={[pal.textLight]} numberOfLines={1}> 65 - @{handle} 66 - </Text> 67 - {isFollowedBy && ( 68 - <View style={s.flexRow}> 69 - <View style={[s.mt5, pal.btn, styles.pill]}> 70 - <Text type="xs" style={pal.text}> 71 - Follows You 72 - </Text> 43 + if (moderation.list.behavior === ModerationBehaviorCode.Hide) { 44 + return null 45 + } 46 + 47 + return ( 48 + <Link 49 + testID={testID} 50 + style={[ 51 + styles.outer, 52 + pal.border, 53 + noBorder && styles.outerNoBorder, 54 + !noBg && pal.view, 55 + ]} 56 + href={`/profile/${profile.handle}`} 57 + title={profile.handle} 58 + asAnchor> 59 + <View style={styles.layout}> 60 + <View style={styles.layoutAvi}> 61 + <UserAvatar 62 + size={40} 63 + avatar={profile.avatar} 64 + moderation={moderation.avatar} 65 + /> 66 + </View> 67 + <View style={styles.layoutContent}> 68 + <Text 69 + type="lg" 70 + style={[s.bold, pal.text]} 71 + numberOfLines={1} 72 + lineHeight={1.2}> 73 + {sanitizeDisplayName(profile.displayName || profile.handle)} 74 + </Text> 75 + <Text type="md" style={[pal.textLight]} numberOfLines={1}> 76 + @{profile.handle} 77 + </Text> 78 + {!!profile.viewer?.followedBy && ( 79 + <View style={s.flexRow}> 80 + <View style={[s.mt5, pal.btn, styles.pill]}> 81 + <Text type="xs" style={pal.text}> 82 + Follows You 83 + </Text> 84 + </View> 73 85 </View> 74 - </View> 75 - )} 86 + )} 87 + </View> 88 + {renderButton ? ( 89 + <View style={styles.layoutButton}>{renderButton()}</View> 90 + ) : undefined} 76 91 </View> 77 - {renderButton ? ( 78 - <View style={styles.layoutButton}>{renderButton()}</View> 92 + {profile.description ? ( 93 + <View style={styles.details}> 94 + <Text style={pal.text} numberOfLines={4}> 95 + {profile.description} 96 + </Text> 97 + </View> 79 98 ) : undefined} 99 + <FollowersList followers={followers} /> 100 + </Link> 101 + ) 102 + }, 103 + ) 104 + 105 + const FollowersList = observer( 106 + ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => { 107 + const store = useStores() 108 + const pal = usePalette('default') 109 + if (!followers?.length) { 110 + return null 111 + } 112 + 113 + const followersWithMods = followers 114 + .map(f => ({ 115 + f, 116 + mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)), 117 + })) 118 + .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide) 119 + 120 + return ( 121 + <View style={styles.followedBy}> 122 + <Text 123 + type="sm" 124 + style={[styles.followsByDesc, pal.textLight]} 125 + numberOfLines={2} 126 + lineHeight={1.2}> 127 + Followed by{' '} 128 + {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} 129 + </Text> 130 + {followersWithMods.slice(0, 3).map(({f, mod}) => ( 131 + <View key={f.did} style={styles.followedByAviContainer}> 132 + <View style={[styles.followedByAvi, pal.view]}> 133 + <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> 134 + </View> 135 + </View> 136 + ))} 80 137 </View> 81 - {description ? ( 82 - <View style={styles.details}> 83 - <Text style={pal.text} numberOfLines={4}> 84 - {description} 85 - </Text> 86 - </View> 87 - ) : undefined} 88 - {followers?.length ? ( 89 - <View style={styles.followedBy}> 90 - <Text 91 - type="sm" 92 - style={[styles.followsByDesc, pal.textLight]} 93 - numberOfLines={2} 94 - lineHeight={1.2}> 95 - Followed by{' '} 96 - {followers.map(f => f.displayName || f.handle).join(', ')} 97 - </Text> 98 - {followers.slice(0, 3).map(f => ( 99 - <View key={f.did} style={styles.followedByAviContainer}> 100 - <View style={[styles.followedByAvi, pal.view]}> 101 - <UserAvatar avatar={f.avatar} size={32} /> 102 - </View> 103 - </View> 104 - ))} 105 - </View> 106 - ) : undefined} 107 - </Link> 108 - ) 109 - } 138 + ) 139 + }, 140 + ) 110 141 111 142 export const ProfileCardWithFollowBtn = observer( 112 143 ({ 113 - did, 114 - handle, 115 - displayName, 116 - avatar, 117 - description, 118 - labels, 119 - isFollowedBy, 144 + profile, 120 145 noBg, 121 146 noBorder, 122 147 followers, 123 148 }: { 124 - did: string 125 - handle: string 126 - displayName?: string 127 - avatar?: string 128 - description?: string 129 - labels: ComAtprotoLabelDefs.Label[] | undefined 130 - isFollowedBy?: boolean 149 + profile: AppBskyActorDefs.ProfileViewBasic 131 150 noBg?: boolean 132 151 noBorder?: boolean 133 152 followers?: AppBskyActorDefs.ProfileView[] | undefined 134 153 }) => { 135 154 const store = useStores() 136 - const isMe = store.me.handle === handle 155 + const isMe = store.me.handle === profile.handle 137 156 138 157 return ( 139 158 <ProfileCard 140 - handle={handle} 141 - displayName={displayName} 142 - avatar={avatar} 143 - description={description} 144 - labels={labels} 145 - isFollowedBy={isFollowedBy} 159 + profile={profile} 146 160 noBg={noBg} 147 161 noBorder={noBorder} 148 162 followers={followers} 149 - renderButton={isMe ? undefined : () => <FollowButton did={did} />} 163 + renderButton={ 164 + isMe ? undefined : () => <FollowButton did={profile.did} /> 165 + } 150 166 /> 151 167 ) 152 168 },
+1 -9
src/view/com/profile/ProfileFollowers.tsx
··· 61 61 // loaded 62 62 // = 63 63 const renderItem = ({item}: {item: FollowerItem}) => ( 64 - <ProfileCardWithFollowBtn 65 - key={item.did} 66 - did={item.did} 67 - handle={item.handle} 68 - displayName={item.displayName} 69 - avatar={item.avatar} 70 - labels={item.labels} 71 - isFollowedBy={!!item.viewer?.followedBy} 72 - /> 64 + <ProfileCardWithFollowBtn key={item.did} profile={item} /> 73 65 ) 74 66 return ( 75 67 <FlatList
+1 -9
src/view/com/profile/ProfileFollows.tsx
··· 58 58 // loaded 59 59 // = 60 60 const renderItem = ({item}: {item: FollowItem}) => ( 61 - <ProfileCardWithFollowBtn 62 - key={item.did} 63 - did={item.did} 64 - handle={item.handle} 65 - displayName={item.displayName} 66 - avatar={item.avatar} 67 - labels={item.labels} 68 - isFollowedBy={!!item.viewer?.followedBy} 69 - /> 61 + <ProfileCardWithFollowBtn key={item.did} profile={item} /> 70 62 ) 71 63 return ( 72 64 <FlatList
+4 -4
src/view/com/profile/ProfileHeader.tsx
··· 26 26 import {RichText} from '../util/text/RichText' 27 27 import {UserAvatar} from '../util/UserAvatar' 28 28 import {UserBanner} from '../util/UserBanner' 29 - import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels' 29 + import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings' 30 30 import {usePalette} from 'lib/hooks/usePalette' 31 31 import {useAnalytics} from 'lib/analytics' 32 32 import {NavigationProp} from 'lib/routes/types' ··· 219 219 ]) 220 220 return ( 221 221 <View style={pal.view}> 222 - <UserBanner banner={view.banner} /> 222 + <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> 223 223 <View style={styles.content}> 224 224 <View style={[styles.buttonsLine]}> 225 225 {isMe ? ( ··· 332 332 richText={view.descriptionRichText} 333 333 /> 334 334 ) : undefined} 335 - <ProfileHeaderLabels labels={view.labels} /> 335 + <ProfileHeaderWarnings moderation={view.moderation.view} /> 336 336 {view.viewer.muted ? ( 337 337 <View 338 338 testID="profileHeaderMutedNotice" ··· 364 364 <UserAvatar 365 365 size={80} 366 366 avatar={view.avatar} 367 - hasWarning={!!view.labels?.length} 367 + moderation={view.moderation.avatar} 368 368 /> 369 369 </View> 370 370 </TouchableWithoutFeedback>
+1 -9
src/view/com/search/SearchResults.tsx
··· 99 99 return ( 100 100 <ScrollView style={pal.view}> 101 101 {model.profiles.map(item => ( 102 - <ProfileCardWithFollowBtn 103 - key={item.did} 104 - did={item.did} 105 - handle={item.handle} 106 - displayName={item.displayName} 107 - avatar={item.avatar} 108 - description={item.description} 109 - labels={item.labels} 110 - /> 102 + <ProfileCardWithFollowBtn key={item.did} profile={item} /> 111 103 ))} 112 104 <View style={s.footerSpacer} /> 113 105 <View style={s.footerSpacer} />
+3 -31
src/view/com/search/Suggestions.tsx
··· 144 144 <View style={[styles.card, pal.view, pal.border]}> 145 145 <ProfileCardWithFollowBtn 146 146 key={item.ref.did} 147 - did={item.ref.did} 148 - handle={item.ref.handle} 149 - displayName={item.ref.displayName} 150 - avatar={item.ref.avatar} 151 - labels={item.ref.labels} 147 + profile={item.ref} 152 148 noBg 153 149 noBorder 154 - description={ 155 - item.ref.description 156 - ? (item.ref as AppBskyActorDefs.ProfileView).description 157 - : '' 158 - } 159 150 followers={ 160 151 item.ref.followers 161 152 ? (item.ref.followers as AppBskyActorDefs.ProfileView[]) ··· 170 161 <View style={[styles.card, pal.view, pal.border]}> 171 162 <ProfileCardWithFollowBtn 172 163 key={item.view.did} 173 - did={item.view.did} 174 - handle={item.view.handle} 175 - displayName={item.view.displayName} 176 - avatar={item.view.avatar} 177 - labels={item.view.labels} 164 + profile={item.view} 178 165 noBg 179 166 noBorder 180 - description={ 181 - item.view.description 182 - ? (item.view as AppBskyActorDefs.ProfileView).description 183 - : '' 184 - } 185 167 /> 186 168 </View> 187 169 ) ··· 191 173 <View style={[styles.card, pal.view, pal.border]}> 192 174 <ProfileCardWithFollowBtn 193 175 key={item.suggested.did} 194 - did={item.suggested.did} 195 - handle={item.suggested.handle} 196 - displayName={item.suggested.displayName} 197 - avatar={item.suggested.avatar} 198 - labels={item.suggested.labels} 176 + profile={item.suggested} 199 177 noBg 200 178 noBorder 201 - description={ 202 - item.suggested.description 203 - ? (item.suggested as AppBskyActorDefs.ProfileView) 204 - .description 205 - : '' 206 - } 207 179 /> 208 180 </View> 209 181 )
+1 -1
src/view/com/util/PostMeta.tsx
··· 97 97 <UserAvatar 98 98 avatar={opts.authorAvatar} 99 99 size={16} 100 - hasWarning={opts.authorHasWarning} 100 + // TODO moderation 101 101 /> 102 102 </View> 103 103 )}
+11 -6
src/view/com/util/UserAvatar.tsx
··· 13 13 import {colors} from 'lib/styles' 14 14 import {DropdownButton} from './forms/DropdownButton' 15 15 import {usePalette} from 'lib/hooks/usePalette' 16 - import {isWeb} from 'platform/detection' 16 + import {isWeb, isAndroid} from 'platform/detection' 17 17 import {Image as RNImage} from 'react-native-image-crop-picker' 18 + import {AvatarModeration} from 'lib/labeling/types' 19 + 20 + const BLUR_AMOUNT = isWeb ? 5 : 100 18 21 19 22 function DefaultAvatar({size}: {size: number}) { 20 23 return ( ··· 40 43 export function UserAvatar({ 41 44 size, 42 45 avatar, 43 - hasWarning, 46 + moderation, 44 47 onSelectNewAvatar, 45 48 }: { 46 49 size: number 47 50 avatar?: string | null 48 - hasWarning?: boolean 51 + moderation?: AvatarModeration 49 52 onSelectNewAvatar?: (img: RNImage | null) => void 50 53 }) { 51 54 const store = useStores() ··· 114 117 ) 115 118 116 119 const warning = useMemo(() => { 117 - if (!hasWarning) { 120 + if (!moderation?.warn) { 118 121 return null 119 122 } 120 123 return ( ··· 126 129 /> 127 130 </View> 128 131 ) 129 - }, [hasWarning, size, pal]) 132 + }, [moderation?.warn, size, pal]) 130 133 131 134 // onSelectNewAvatar is only passed as prop on the EditProfile component 132 135 return onSelectNewAvatar ? ( ··· 159 162 /> 160 163 </View> 161 164 </DropdownButton> 162 - ) : avatar ? ( 165 + ) : avatar && 166 + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( 163 167 <View style={{width: size, height: size}}> 164 168 <HighPriorityImage 165 169 testID="userAvatarImage" 166 170 style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} 167 171 contentFit="cover" 168 172 source={{uri: avatar}} 173 + blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} 169 174 /> 170 175 {warning} 171 176 </View>
+7 -2
src/view/com/util/UserBanner.tsx
··· 13 13 } from 'lib/hooks/usePermissions' 14 14 import {DropdownButton} from './forms/DropdownButton' 15 15 import {usePalette} from 'lib/hooks/usePalette' 16 - import {isWeb} from 'platform/detection' 16 + import {AvatarModeration} from 'lib/labeling/types' 17 + import {isWeb, isAndroid} from 'platform/detection' 17 18 18 19 export function UserBanner({ 19 20 banner, 21 + moderation, 20 22 onSelectNewBanner, 21 23 }: { 22 24 banner?: string | null 25 + moderation?: AvatarModeration 23 26 onSelectNewBanner?: (img: TImage | null) => void 24 27 }) { 25 28 const store = useStores() ··· 107 110 /> 108 111 </View> 109 112 </DropdownButton> 110 - ) : banner ? ( 113 + ) : banner && 114 + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( 111 115 <Image 112 116 testID="userBannerImage" 113 117 style={styles.bannerImage} 114 118 resizeMode="cover" 115 119 source={{uri: banner}} 120 + blurRadius={moderation?.blur ? 100 : 0} 116 121 /> 117 122 ) : ( 118 123 <View
+1 -1
src/view/com/util/error/ErrorScreen.tsx
··· 35 35 ]}> 36 36 <FontAwesomeIcon 37 37 icon="exclamation" 38 - style={pal.textInverted} 38 + style={pal.textInverted as FontAwesomeIconStyle} 39 39 size={24} 40 40 /> 41 41 </View>
+10 -15
src/view/com/util/moderation/ContentHider.tsx
··· 6 6 View, 7 7 ViewStyle, 8 8 } from 'react-native' 9 - import {ComAtprotoLabelDefs} from '@atproto/api' 10 9 import {usePalette} from 'lib/hooks/usePalette' 11 - import {useStores} from 'state/index' 12 10 import {Text} from '../text/Text' 13 11 import {addStyle} from 'lib/styles' 12 + import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' 14 13 15 14 export function ContentHider({ 16 15 testID, 17 - isMuted, 18 - labels, 16 + moderation, 19 17 style, 20 18 containerStyle, 21 19 children, 22 20 }: React.PropsWithChildren<{ 23 21 testID?: string 24 - isMuted?: boolean 25 - labels: ComAtprotoLabelDefs.Label[] | undefined 22 + moderation: ModerationBehavior 26 23 style?: StyleProp<ViewStyle> 27 24 containerStyle?: StyleProp<ViewStyle> 28 25 }>) { 29 26 const pal = usePalette('default') 30 27 const [override, setOverride] = React.useState(false) 31 - const store = useStores() 32 - const labelPref = store.preferences.getLabelPreference(labels) 33 28 34 - if (!isMuted && labelPref.pref === 'show') { 29 + if ( 30 + moderation.behavior === ModerationBehaviorCode.Show || 31 + moderation.behavior === ModerationBehaviorCode.Warn || 32 + moderation.behavior === ModerationBehaviorCode.WarnImages 33 + ) { 35 34 return ( 36 35 <View testID={testID} style={style}> 37 36 {children} ··· 39 38 ) 40 39 } 41 40 42 - if (labelPref.pref === 'hide') { 41 + if (moderation.behavior === ModerationBehaviorCode.Hide) { 43 42 return null 44 43 } 45 44 ··· 52 51 override && styles.descriptionOpen, 53 52 ]}> 54 53 <Text type="md" style={pal.textLight}> 55 - {isMuted ? ( 56 - <>Post from an account you muted.</> 57 - ) : ( 58 - <>Warning: {labelPref.desc.warning || labelPref.desc.title}</> 59 - )} 54 + {moderation.reason || 'Content warning'} 60 55 </Text> 61 56 <TouchableOpacity 62 57 style={styles.showBtn}
+40 -45
src/view/com/util/moderation/PostHider.tsx
··· 6 6 View, 7 7 ViewStyle, 8 8 } from 'react-native' 9 - import {ComAtprotoLabelDefs} from '@atproto/api' 10 9 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 10 import {usePalette} from 'lib/hooks/usePalette' 12 11 import {Link} from '../Link' 13 12 import {Text} from '../text/Text' 14 13 import {addStyle} from 'lib/styles' 15 - import {useStores} from 'state/index' 14 + import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' 16 15 17 16 export function PostHider({ 18 17 testID, 19 18 href, 20 - isMuted, 21 - labels, 19 + moderation, 22 20 style, 23 21 children, 24 22 }: React.PropsWithChildren<{ 25 23 testID?: string 26 - href: string 27 - isMuted: boolean | undefined 28 - labels: ComAtprotoLabelDefs.Label[] | undefined 24 + href?: string 25 + moderation: ModerationBehavior 29 26 style: StyleProp<ViewStyle> 30 27 }>) { 31 - const store = useStores() 32 28 const pal = usePalette('default') 33 29 const [override, setOverride] = React.useState(false) 34 30 const bg = override ? pal.viewLight : pal.view 35 31 36 - const labelPref = store.preferences.getLabelPreference(labels) 37 - if (labelPref.pref === 'hide') { 38 - return <></> 32 + if (moderation.behavior === ModerationBehaviorCode.Hide) { 33 + return null 39 34 } 40 35 41 - if (!isMuted) { 42 - // NOTE: any further label enforcement should occur in ContentContainer 36 + if (moderation.behavior === ModerationBehaviorCode.Warn) { 43 37 return ( 44 - <Link testID={testID} style={style} href={href} noFeedback> 45 - {children} 46 - </Link> 38 + <> 39 + <View style={[styles.description, bg, pal.border]}> 40 + <FontAwesomeIcon 41 + icon={['far', 'eye-slash']} 42 + style={[styles.icon, pal.text]} 43 + /> 44 + <Text type="md" style={pal.textLight}> 45 + {moderation.reason || 'Content warning'} 46 + </Text> 47 + <TouchableOpacity 48 + style={styles.showBtn} 49 + onPress={() => setOverride(v => !v)}> 50 + <Text type="md" style={pal.link}> 51 + {override ? 'Hide' : 'Show'} post 52 + </Text> 53 + </TouchableOpacity> 54 + </View> 55 + {override && ( 56 + <View style={[styles.childrenContainer, pal.border, bg]}> 57 + <Link 58 + testID={testID} 59 + style={addStyle(style, styles.child)} 60 + href={href} 61 + noFeedback> 62 + {children} 63 + </Link> 64 + </View> 65 + )} 66 + </> 47 67 ) 48 68 } 49 69 70 + // NOTE: any further label enforcement should occur in ContentContainer 50 71 return ( 51 - <> 52 - <View style={[styles.description, bg, pal.border]}> 53 - <FontAwesomeIcon 54 - icon={['far', 'eye-slash']} 55 - style={[styles.icon, pal.text]} 56 - /> 57 - <Text type="md" style={pal.textLight}> 58 - Post from an account you muted. 59 - </Text> 60 - <TouchableOpacity 61 - style={styles.showBtn} 62 - onPress={() => setOverride(v => !v)}> 63 - <Text type="md" style={pal.link}> 64 - {override ? 'Hide' : 'Show'} post 65 - </Text> 66 - </TouchableOpacity> 67 - </View> 68 - {override && ( 69 - <View style={[styles.childrenContainer, pal.border, bg]}> 70 - <Link 71 - testID={testID} 72 - style={addStyle(style, styles.child)} 73 - href={href} 74 - noFeedback> 75 - {children} 76 - </Link> 77 - </View> 78 - )} 79 - </> 72 + <Link testID={testID} style={style} href={href} noFeedback> 73 + {children} 74 + </Link> 80 75 ) 81 76 } 82 77
-55
src/view/com/util/moderation/ProfileHeaderLabels.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {ComAtprotoLabelDefs} from '@atproto/api' 4 - import { 5 - FontAwesomeIcon, 6 - FontAwesomeIconStyle, 7 - } from '@fortawesome/react-native-fontawesome' 8 - import {Text} from '../text/Text' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {getLabelValueGroup} from 'lib/labeling/helpers' 11 - 12 - export function ProfileHeaderLabels({ 13 - labels, 14 - }: { 15 - labels: ComAtprotoLabelDefs.Label[] | undefined 16 - }) { 17 - const palErr = usePalette('error') 18 - if (!labels?.length) { 19 - return null 20 - } 21 - return ( 22 - <> 23 - {labels.map((label, i) => { 24 - const labelGroup = getLabelValueGroup(label?.val || '') 25 - return ( 26 - <View 27 - key={`${label.val}-${i}`} 28 - style={[styles.container, palErr.border, palErr.view]}> 29 - <FontAwesomeIcon 30 - icon="circle-exclamation" 31 - style={palErr.text as FontAwesomeIconStyle} 32 - size={20} 33 - /> 34 - <Text style={palErr.text}> 35 - This account has been flagged for{' '} 36 - {(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}. 37 - </Text> 38 - </View> 39 - ) 40 - })} 41 - </> 42 - ) 43 - } 44 - 45 - const styles = StyleSheet.create({ 46 - container: { 47 - flexDirection: 'row', 48 - alignItems: 'center', 49 - gap: 10, 50 - borderWidth: 1, 51 - borderRadius: 6, 52 - paddingHorizontal: 10, 53 - paddingVertical: 8, 54 - }, 55 - })
+44
src/view/com/util/moderation/ProfileHeaderWarnings.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import {Text} from '../text/Text' 8 + import {usePalette} from 'lib/hooks/usePalette' 9 + import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types' 10 + 11 + export function ProfileHeaderWarnings({ 12 + moderation, 13 + }: { 14 + moderation: ModerationBehavior 15 + }) { 16 + const palErr = usePalette('error') 17 + if (moderation.behavior === ModerationBehaviorCode.Show) { 18 + return null 19 + } 20 + return ( 21 + <View style={[styles.container, palErr.border, palErr.view]}> 22 + <FontAwesomeIcon 23 + icon="circle-exclamation" 24 + style={palErr.text as FontAwesomeIconStyle} 25 + size={20} 26 + /> 27 + <Text style={palErr.text}> 28 + This account has been flagged: {moderation.reason} 29 + </Text> 30 + </View> 31 + ) 32 + } 33 + 34 + const styles = StyleSheet.create({ 35 + container: { 36 + flexDirection: 'row', 37 + alignItems: 'center', 38 + gap: 10, 39 + borderWidth: 1, 40 + borderRadius: 6, 41 + paddingHorizontal: 10, 42 + paddingVertical: 8, 43 + }, 44 + })
+129
src/view/com/util/moderation/ScreenHider.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import {useNavigation} from '@react-navigation/native' 8 + import {usePalette} from 'lib/hooks/usePalette' 9 + import {NavigationProp} from 'lib/routes/types' 10 + import {Text} from '../text/Text' 11 + import {Button} from '../forms/Button' 12 + import {isDesktopWeb} from 'platform/detection' 13 + import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types' 14 + 15 + export function ScreenHider({ 16 + testID, 17 + screenDescription, 18 + moderation, 19 + style, 20 + containerStyle, 21 + children, 22 + }: React.PropsWithChildren<{ 23 + testID?: string 24 + screenDescription: string 25 + moderation: ModerationBehavior 26 + style?: StyleProp<ViewStyle> 27 + containerStyle?: StyleProp<ViewStyle> 28 + }>) { 29 + const pal = usePalette('default') 30 + const palInverted = usePalette('inverted') 31 + const [override, setOverride] = React.useState(false) 32 + const navigation = useNavigation<NavigationProp>() 33 + 34 + const onPressBack = React.useCallback(() => { 35 + if (navigation.canGoBack()) { 36 + navigation.goBack() 37 + } else { 38 + navigation.navigate('Home') 39 + } 40 + }, [navigation]) 41 + 42 + if (moderation.behavior !== ModerationBehaviorCode.Hide || override) { 43 + return ( 44 + <View testID={testID} style={style}> 45 + {children} 46 + </View> 47 + ) 48 + } 49 + 50 + return ( 51 + <View style={[styles.container, pal.view, containerStyle]}> 52 + <View style={styles.iconContainer}> 53 + <View style={[styles.icon, palInverted.view]}> 54 + <FontAwesomeIcon 55 + icon="exclamation" 56 + style={pal.textInverted as FontAwesomeIconStyle} 57 + size={24} 58 + /> 59 + </View> 60 + </View> 61 + <Text type="title-2xl" style={[styles.title, pal.text]}> 62 + Content Warning 63 + </Text> 64 + <Text type="2xl" style={[styles.description, pal.textLight]}> 65 + This {screenDescription} has been flagged:{' '} 66 + {moderation.reason || 'Content warning'} 67 + </Text> 68 + {!isDesktopWeb && <View style={styles.spacer} />} 69 + <View style={styles.btnContainer}> 70 + <Button type="inverted" onPress={onPressBack} style={styles.btn}> 71 + <Text type="button-lg" style={pal.textInverted}> 72 + Go back 73 + </Text> 74 + </Button> 75 + {!moderation.noOverride && ( 76 + <Button 77 + type="default" 78 + onPress={() => setOverride(v => !v)} 79 + style={styles.btn}> 80 + <Text type="button-lg" style={pal.text}> 81 + Show anyway 82 + </Text> 83 + </Button> 84 + )} 85 + </View> 86 + </View> 87 + ) 88 + } 89 + 90 + const styles = StyleSheet.create({ 91 + spacer: { 92 + flex: 1, 93 + }, 94 + container: { 95 + flex: 1, 96 + paddingTop: 100, 97 + paddingBottom: 150, 98 + }, 99 + iconContainer: { 100 + alignItems: 'center', 101 + marginBottom: 10, 102 + }, 103 + icon: { 104 + borderRadius: 25, 105 + width: 50, 106 + height: 50, 107 + alignItems: 'center', 108 + justifyContent: 'center', 109 + }, 110 + title: { 111 + textAlign: 'center', 112 + marginBottom: 10, 113 + }, 114 + description: { 115 + marginBottom: 10, 116 + paddingHorizontal: 20, 117 + textAlign: 'center', 118 + }, 119 + btnContainer: { 120 + flexDirection: 'row', 121 + justifyContent: 'center', 122 + marginVertical: 10, 123 + gap: 10, 124 + }, 125 + btn: { 126 + paddingHorizontal: 20, 127 + paddingVertical: 14, 128 + }, 129 + })
+7 -2
src/view/screens/Profile.tsx
··· 6 6 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 7 7 import {ViewSelector} from '../com/util/ViewSelector' 8 8 import {CenteredView} from '../com/util/Views' 9 + import {ScreenHider} from 'view/com/util/moderation/ScreenHider' 9 10 import {ProfileUiModel} from 'state/models/ui/profile' 10 11 import {useStores} from 'state/index' 11 12 import {PostsFeedSliceModel} from 'state/models/feeds/posts' ··· 140 141 ) 141 142 142 143 return ( 143 - <View testID="profileView" style={styles.container}> 144 + <ScreenHider 145 + testID="profileView" 146 + style={styles.container} 147 + screenDescription="profile" 148 + moderation={uiState.profile.moderation.view}> 144 149 {uiState.profile.hasError ? ( 145 150 <ErrorScreen 146 151 testID="profileErrorScreen" ··· 169 174 onPress={onPressCompose} 170 175 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 171 176 /> 172 - </View> 177 + </ScreenHider> 173 178 ) 174 179 }), 175 180 )
+8 -13
src/view/screens/SearchMobile.tsx
··· 146 146 scrollEventThrottle={100}> 147 147 {query && autocompleteView.searchRes.length ? ( 148 148 <> 149 - {autocompleteView.searchRes.map( 150 - ({did, handle, displayName, labels, avatar}, index) => ( 151 - <ProfileCard 152 - key={did} 153 - testID={`searchAutoCompleteResult-${handle}`} 154 - handle={handle} 155 - displayName={displayName} 156 - labels={labels} 157 - avatar={avatar} 158 - noBorder={index === 0} 159 - /> 160 - ), 161 - )} 149 + {autocompleteView.searchRes.map((profile, index) => ( 150 + <ProfileCard 151 + key={profile.did} 152 + testID={`searchAutoCompleteResult-${profile.handle}`} 153 + profile={profile} 154 + noBorder={index === 0} 155 + /> 156 + ))} 162 157 </> 163 158 ) : query && !autocompleteView.searchRes.length ? ( 164 159 <View>
+1 -8
src/view/shell/desktop/Search.tsx
··· 85 85 {autocompleteView.searchRes.length ? ( 86 86 <> 87 87 {autocompleteView.searchRes.map((item, i) => ( 88 - <ProfileCard 89 - key={item.did} 90 - handle={item.handle} 91 - displayName={item.displayName} 92 - avatar={item.avatar} 93 - labels={item.labels} 94 - noBorder={i === 0} 95 - /> 88 + <ProfileCard key={item.did} profile={item} noBorder={i === 0} /> 96 89 ))} 97 90 </> 98 91 ) : (
+4 -4
yarn.lock
··· 30 30 tlds "^1.234.0" 31 31 typed-emitter "^2.1.0" 32 32 33 - "@atproto/api@0.2.9": 34 - version "0.2.9" 35 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.9.tgz#08e29da66d1a9001d9d3ce427548c1760d805e99" 36 - integrity sha512-r00IqidX2YF3VUEa4MUO2Vxqp3+QhI1cSNcWgzT4LsANapzrwdDTM+rY2Ejp9na3F+unO4SWRW3o434cVmG5gw== 33 + "@atproto/api@0.2.10": 34 + version "0.2.10" 35 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.10.tgz#19c4d695f88ab4e45e4c9f2f4db5fad61590a3d2" 36 + integrity sha512-97UBtvIXhsgNO7bXhHk0JwDNwyqTcL1N0JT2rnXjUeLKNf2hDvomFtI50Y4RFU942uUS5W5VtM+JJuZO5Ryw5w== 37 37 dependencies: 38 38 "@atproto/common-web" "*" 39 39 "@atproto/uri" "*"