Refactor moderation to apply to accounts, profiles, and posts correctly (#548)

* Add ScreenHider component

* Add blur attribute to UserAvatar and UserBanner

* Remove dead suggested posts component and model

* Bump @atproto/api@0.2.10

* Rework moderation tooling to give a more precise DSL

* Add label mocks

* Apply finer grained moderation controls

* Refactor ProfileCard to just take the profile object

* Apply moderation to user listings and banner

* Apply moderation to notifications

* Fix lint

* Tune avatar & banner blur settings per platform

* 1.24
zio/stable
Paul Frazee 2023-04-27 12:38:23 -05:00 committed by GitHub
parent 51be8474db
commit 1d50ddb378
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1195 additions and 763 deletions

View File

@ -63,6 +63,120 @@ async function main() {
}, },
}) })
} }
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'csam-account',
'csam-profile',
'csam-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'muted-account',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('csam', 'csam-account')
await server.mocker.labelProfile('csam', 'csam-profile')
await server.mocker.labelPost(
'csam',
await server.mocker.createPost('csam-posts', 'csam post'),
)
await server.mocker.labelPost(
'csam',
await server.mocker.createQuotePost(
'csam-posts',
'csam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'csam',
await server.mocker.createReply(
'csam-posts',
'csam reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createPost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createPost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'account reply',
anchorPost,
)
}
} }
console.log('Ready') console.log('Ready')
return res.writeHead(200).end(server.pdsUrl) return res.writeHead(200).end(server.pdsUrl)

View File

@ -3,7 +3,7 @@
"name": "Bluesky", "name": "Bluesky",
"slug": "bluesky", "slug": "bluesky",
"owner": "blueskysocial", "owner": "blueskysocial",
"version": "1.23.0", "version": "1.24.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",
@ -34,7 +34,7 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"android": { "android": {
"versionCode": 8, "versionCode": 9,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"

View File

@ -2,6 +2,7 @@ import {AddressInfo} from 'net'
import os from 'os' import os from 'os'
import net from 'net' import net from 'net'
import path from 'path' import path from 'path'
import fs from 'fs'
import * as crypto from '@atproto/crypto' import * as crypto from '@atproto/crypto'
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds' import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
import * as plc from '@did-plc/lib' import * as plc from '@did-plc/lib'
@ -104,9 +105,13 @@ export async function createServer(
await pds.start() await pds.start()
const pdsUrl = `http://localhost:${port}` const pdsUrl = `http://localhost:${port}`
const profilePic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
)
return { return {
pdsUrl, pdsUrl,
mocker: new Mocker(pdsUrl), mocker: new Mocker(pds, pdsUrl, profilePic),
async close() { async close() {
await pds.destroy() await pds.destroy()
await plcServer.destroy() await plcServer.destroy()
@ -118,7 +123,11 @@ class Mocker {
agent: BskyAgent agent: BskyAgent
users: Record<string, TestUser> = {} users: Record<string, TestUser> = {}
constructor(public service: string) { constructor(
public pds: PDS,
public service: string,
public profilePic: Uint8Array,
) {
this.agent = new BskyAgent({service}) this.agent = new BskyAgent({service})
} }
@ -152,6 +161,15 @@ class Mocker {
handle: name + '.test', handle: name + '.test',
password: 'hunter2', password: 'hunter2',
}) })
await agent.upsertProfile(async () => {
const blob = await agent.uploadBlob(this.profilePic, {
encoding: 'image/jpeg',
})
return {
displayName: name,
avatar: blob.data.blob,
}
})
this.users[name] = { this.users[name] = {
did: res.data.did, did: res.data.did,
email, email,
@ -192,6 +210,133 @@ class Mocker {
await this.follow('carla', 'alice') await this.follow('carla', 'alice')
await this.follow('carla', 'bob') await this.follow('carla', 'bob')
} }
async createPost(user: string, text: string) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
return await agent.post({
text,
createdAt: new Date().toISOString(),
})
}
async createQuotePost(
user: string,
text: string,
{uri, cid}: {uri: string; cid: string},
) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
return await agent.post({
text,
embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
createdAt: new Date().toISOString(),
})
}
async createReply(
user: string,
text: string,
{uri, cid}: {uri: string; cid: string},
) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
return await agent.post({
text,
reply: {root: {uri, cid}, parent: {uri, cid}},
createdAt: new Date().toISOString(),
})
}
async like(user: string, {uri, cid}: {uri: string; cid: string}) {
const agent = this.users[user]?.agent
if (!agent) {
throw new Error(`Not a user: ${user}`)
}
return await agent.like(uri, cid)
}
async labelAccount(label: string, user: string) {
const did = this.users[user]?.did
if (!did) {
throw new Error(`Invalid user: ${user}`)
}
const ctx = this.pds.ctx
if (!ctx) {
throw new Error('Invalid PDS')
}
await ctx.db.db
.insertInto('label')
.values([
{
src: ctx.cfg.labelerDid,
uri: did,
cid: '',
val: label,
neg: 0,
cts: new Date().toISOString(),
},
])
.execute()
}
async labelProfile(label: string, user: string) {
const agent = this.users[user]?.agent
const did = this.users[user]?.did
if (!did) {
throw new Error(`Invalid user: ${user}`)
}
const profile = await agent.app.bsky.actor.profile.get({
repo: user + '.test',
rkey: 'self',
})
const ctx = this.pds.ctx
if (!ctx) {
throw new Error('Invalid PDS')
}
await ctx.db.db
.insertInto('label')
.values([
{
src: ctx.cfg.labelerDid,
uri: profile.uri,
cid: profile.cid,
val: label,
neg: 0,
cts: new Date().toISOString(),
},
])
.execute()
}
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
const ctx = this.pds.ctx
if (!ctx) {
throw new Error('Invalid PDS')
}
await ctx.db.db
.insertInto('label')
.values([
{
src: ctx.cfg.labelerDid,
uri,
cid,
val: label,
neg: 0,
cts: new Date().toISOString(),
},
])
.execute()
}
} }
const checkAvailablePort = (port: number) => const checkAvailablePort = (port: number) =>

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.23.0", "version": "1.24.0",
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "patch-package", "postinstall": "patch-package",
@ -22,7 +22,7 @@
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.2.9", "@atproto/api": "0.2.10",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@expo/webpack-config": "^18.0.1", "@expo/webpack-config": "^18.0.1",

View File

@ -1,23 +1,20 @@
import {LabelPreferencesModel} from 'state/models/ui/preferences' import {LabelPreferencesModel} from 'state/models/ui/preferences'
import {LabelValGroup} from './types'
export interface LabelValGroup {
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
title: string
subtitle?: string
warning?: string
values: string[]
}
export const ILLEGAL_LABEL_GROUP: LabelValGroup = { export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
id: 'illegal', id: 'illegal',
title: 'Illegal Content', title: 'Illegal Content',
warning: 'Illegal Content',
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'], values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
imagesOnly: false, // not applicable
} }
export const UNKNOWN_LABEL_GROUP: LabelValGroup = { export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
id: 'unknown', id: 'unknown',
title: 'Unknown Label', title: 'Unknown Label',
warning: 'Content Warning',
values: [], values: [],
imagesOnly: false,
} }
export const CONFIGURABLE_LABEL_GROUPS: Record< export const CONFIGURABLE_LABEL_GROUPS: Record<
@ -30,6 +27,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'i.e. Pornography', subtitle: 'i.e. Pornography',
warning: 'Sexually Explicit', warning: 'Sexually Explicit',
values: ['porn'], values: ['porn'],
imagesOnly: false, // apply to whole thing
}, },
nudity: { nudity: {
id: 'nudity', id: 'nudity',
@ -37,6 +35,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Including non-sexual and artistic', subtitle: 'Including non-sexual and artistic',
warning: 'Nudity', warning: 'Nudity',
values: ['nudity'], values: ['nudity'],
imagesOnly: true,
}, },
suggestive: { suggestive: {
id: 'suggestive', id: 'suggestive',
@ -44,6 +43,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Does not include nudity', subtitle: 'Does not include nudity',
warning: 'Sexually Suggestive', warning: 'Sexually Suggestive',
values: ['sexual'], values: ['sexual'],
imagesOnly: true,
}, },
gore: { gore: {
id: 'gore', id: 'gore',
@ -51,12 +51,14 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Gore, self-harm, torture', subtitle: 'Gore, self-harm, torture',
warning: 'Violence', warning: 'Violence',
values: ['gore', 'self-harm', 'torture'], values: ['gore', 'self-harm', 'torture'],
imagesOnly: true,
}, },
hate: { hate: {
id: 'hate', id: 'hate',
title: 'Political Hate-Groups', title: 'Political Hate-Groups',
warning: 'Hate', warning: 'Hate',
values: ['icon-kkk', 'icon-nazi'], values: ['icon-kkk', 'icon-nazi'],
imagesOnly: false,
}, },
spam: { spam: {
id: 'spam', id: 'spam',
@ -64,6 +66,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Excessive low-quality posts', subtitle: 'Excessive low-quality posts',
warning: 'Spam', warning: 'Spam',
values: ['spam'], values: ['spam'],
imagesOnly: false,
}, },
impersonation: { impersonation: {
id: 'impersonation', id: 'impersonation',
@ -71,5 +74,6 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
subtitle: 'Accounts falsely claiming to be people or orgs', subtitle: 'Accounts falsely claiming to be people or orgs',
warning: 'Impersonation', warning: 'Impersonation',
values: ['impersonation'], values: ['impersonation'],
imagesOnly: false,
}, },
} }

View File

@ -1,9 +1,33 @@
import { import {
LabelValGroup, AppBskyActorDefs,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
import {
CONFIGURABLE_LABEL_GROUPS, CONFIGURABLE_LABEL_GROUPS,
ILLEGAL_LABEL_GROUP, ILLEGAL_LABEL_GROUP,
UNKNOWN_LABEL_GROUP, UNKNOWN_LABEL_GROUP,
} from './const' } from './const'
import {
Label,
LabelValGroup,
ModerationBehaviorCode,
PostModeration,
ProfileModeration,
PostLabelInfo,
ProfileLabelInfo,
} from './types'
import {RootStoreModel} from 'state/index'
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
| AppBskyEmbedExternal.View
| AppBskyEmbedRecordWithMedia.View
| {$type: string; [k: string]: unknown}
export function getLabelValueGroup(labelVal: string): LabelValGroup { export function getLabelValueGroup(labelVal: string): LabelValGroup {
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
@ -17,3 +41,280 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
} }
return UNKNOWN_LABEL_GROUP return UNKNOWN_LABEL_GROUP
} }
export function getPostModeration(
store: RootStoreModel,
postInfo: PostLabelInfo,
): PostModeration {
const accountPref = store.preferences.getLabelPreference(
postInfo.accountLabels,
)
const profilePref = store.preferences.getLabelPreference(
postInfo.profileLabels,
)
const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
// avatar
let avatar = {
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
blur:
accountPref.pref === 'hide' ||
accountPref.pref === 'warn' ||
profilePref.pref === 'hide' ||
profilePref.pref === 'warn',
}
// hide no-override cases
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
return hidePostNoOverride(accountPref.desc.warning)
}
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
return hidePostNoOverride(profilePref.desc.warning)
}
if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
return hidePostNoOverride(postPref.desc.warning)
}
// hide cases
if (accountPref.pref === 'hide') {
return {
avatar,
list: hide(accountPref.desc.warning),
thread: hide(accountPref.desc.warning),
view: warn(accountPref.desc.warning),
}
}
if (profilePref.pref === 'hide') {
return {
avatar,
list: hide(profilePref.desc.warning),
thread: hide(profilePref.desc.warning),
view: warn(profilePref.desc.warning),
}
}
if (postPref.pref === 'hide') {
return {
avatar,
list: hide(postPref.desc.warning),
thread: hide(postPref.desc.warning),
view: warn(postPref.desc.warning),
}
}
// muting
if (postInfo.isMuted) {
return {
avatar,
list: hide('Post from an account you muted.'),
thread: warn('Post from an account you muted.'),
view: warn('Post from an account you muted.'),
}
}
// warning cases
if (postPref.pref === 'warn') {
if (postPref.desc.imagesOnly) {
return {
avatar,
list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
}
}
return {
avatar,
list: warnContent(postPref.desc.warning),
thread: warnContent(postPref.desc.warning),
view: warnContent(postPref.desc.warning),
}
}
if (accountPref.pref === 'warn') {
return {
avatar,
list: warnContent(accountPref.desc.warning),
thread: warnContent(accountPref.desc.warning),
view: warnContent(accountPref.desc.warning),
}
}
return {
avatar,
list: show(),
thread: show(),
view: show(),
}
}
export function getProfileModeration(
store: RootStoreModel,
profileLabels: ProfileLabelInfo,
): ProfileModeration {
const accountPref = store.preferences.getLabelPreference(
profileLabels.accountLabels,
)
const profilePref = store.preferences.getLabelPreference(
profileLabels.profileLabels,
)
// avatar
let avatar = {
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
blur:
accountPref.pref === 'hide' ||
accountPref.pref === 'warn' ||
profilePref.pref === 'hide' ||
profilePref.pref === 'warn',
}
// hide no-override cases
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
return hideProfileNoOverride(accountPref.desc.warning)
}
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
return hideProfileNoOverride(profilePref.desc.warning)
}
// hide cases
if (accountPref.pref === 'hide') {
return {
avatar,
list: hide(accountPref.desc.warning),
view: hide(accountPref.desc.warning),
}
}
if (profilePref.pref === 'hide') {
return {
avatar,
list: hide(profilePref.desc.warning),
view: hide(profilePref.desc.warning),
}
}
// warn cases
if (accountPref.pref === 'warn') {
return {
avatar,
list: warn(accountPref.desc.warning),
view: warn(accountPref.desc.warning),
}
}
// we don't warn for this
// if (profilePref.pref === 'warn') {
// return {
// avatar,
// list: warn(profilePref.desc.warning),
// view: warn(profilePref.desc.warning),
// }
// }
return {
avatar,
list: show(),
view: show(),
}
}
export function getProfileViewBasicLabelInfo(
profile: AppBskyActorDefs.ProfileViewBasic,
): ProfileLabelInfo {
return {
accountLabels: filterAccountLabels(profile.labels),
profileLabels: filterProfileLabels(profile.labels),
isMuted: profile.viewer?.muted || false,
}
}
export function getEmbedLabels(embed?: Embed): Label[] {
if (!embed) {
return []
}
if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value) &&
AppBskyFeedPost.validateRecord(embed.record.record.value).success
) {
return embed.record.record.labels || []
}
return []
}
export function filterAccountLabels(labels?: Label[]): Label[] {
if (!labels) {
return []
}
return labels.filter(
label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
)
}
export function filterProfileLabels(labels?: Label[]): Label[] {
if (!labels) {
return []
}
return labels.filter(label =>
label.uri.endsWith('/app.bsky.actor.profile/self'),
)
}
// internal methods
// =
function show() {
return {
behavior: ModerationBehaviorCode.Show,
}
}
function hidePostNoOverride(reason: string) {
return {
avatar: {warn: true, blur: true},
list: hideNoOverride(reason),
thread: hideNoOverride(reason),
view: hideNoOverride(reason),
}
}
function hideProfileNoOverride(reason: string) {
return {
avatar: {warn: true, blur: true},
list: hideNoOverride(reason),
view: hideNoOverride(reason),
}
}
function hideNoOverride(reason: string) {
return {
behavior: ModerationBehaviorCode.Hide,
reason,
noOverride: true,
}
}
function hide(reason: string) {
return {
behavior: ModerationBehaviorCode.Hide,
reason,
}
}
function warn(reason: string) {
return {
behavior: ModerationBehaviorCode.Warn,
reason,
}
}
function warnContent(reason: string) {
return {
behavior: ModerationBehaviorCode.WarnContent,
reason,
}
}
function warnImages(reason: string) {
return {
behavior: ModerationBehaviorCode.WarnImages,
reason,
}
}

View File

@ -0,0 +1,58 @@
import {ComAtprotoLabelDefs} from '@atproto/api'
import {LabelPreferencesModel} from 'state/models/ui/preferences'
export type Label = ComAtprotoLabelDefs.Label
export interface LabelValGroup {
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
title: string
imagesOnly: boolean
subtitle?: string
warning: string
values: string[]
}
export interface PostLabelInfo {
postLabels: Label[]
accountLabels: Label[]
profileLabels: Label[]
isMuted: boolean
}
export interface ProfileLabelInfo {
accountLabels: Label[]
profileLabels: Label[]
isMuted: boolean
}
export enum ModerationBehaviorCode {
Show,
Hide,
Warn,
WarnContent,
WarnImages,
}
export interface ModerationBehavior {
behavior: ModerationBehaviorCode
noOverride?: boolean
reason?: string
}
export interface AvatarModeration {
warn: boolean
blur: boolean
}
export interface PostModeration {
avatar: AvatarModeration
list: ModerationBehavior
thread: ModerationBehavior
view: ModerationBehavior
}
export interface ProfileModeration {
avatar: AvatarModeration
list: ModerationBehavior
view: ModerationBehavior
}

View File

@ -10,6 +10,13 @@ import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {updateDataOptimistically} from 'lib/async/revertible' import {updateDataOptimistically} from 'lib/async/revertible'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
filterAccountLabels,
filterProfileLabels,
getPostModeration,
} from 'lib/labeling/helpers'
export class PostThreadItemModel { export class PostThreadItemModel {
// ui state // ui state
@ -46,6 +53,21 @@ export class PostThreadItemModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri) return this.rootStore.mutedThreads.uris.has(this.rootUri)
} }
get labelInfo(): PostLabelInfo {
return {
postLabels: (this.post.labels || []).concat(
getEmbedLabels(this.post.embed),
),
accountLabels: filterAccountLabels(this.post.author.labels),
profileLabels: filterProfileLabels(this.post.author.labels),
isMuted: this.post.author.viewer?.muted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
v: AppBskyFeedDefs.ThreadViewPost, v: AppBskyFeedDefs.ThreadViewPost,

View File

@ -1,122 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {AppBskyFeedPost as Post} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
type RemoveIndex<T> = {
[P in keyof T as string extends P
? never
: number extends P
? never
: P]: T[P]
}
export class PostModel implements RemoveIndex<Post.Record> {
// state
isLoading = false
hasLoaded = false
error = ''
uri: string = ''
// data
text: string = ''
entities?: Post.Entity[]
reply?: Post.ReplyRef
createdAt: string = ''
constructor(public rootStore: RootStoreModel, uri: string) {
makeAutoObservable(
this,
{
rootStore: false,
uri: false,
},
{autoBind: true},
)
this.uri = uri
}
get hasContent() {
return this.createdAt !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get rootUri(): string {
if (this.reply?.root.uri) {
return this.reply.root.uri
}
return this.uri
}
get isThreadMuted() {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
// public api
// =
async setup() {
await this._load()
}
async toggleThreadMute() {
if (this.isThreadMuted) {
this.rootStore.mutedThreads.uris.delete(this.rootUri)
} else {
this.rootStore.mutedThreads.uris.add(this.rootUri)
}
}
// state transitions
// =
_xLoading() {
this.isLoading = true
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch post', err)
}
}
// loader functions
// =
async _load() {
this._xLoading()
try {
const urip = new AtUri(this.uri)
const res = await this.rootStore.agent.getPost({
repo: urip.host,
rkey: urip.rkey,
})
// TODO
// if (!res.valid) {
// throw new Error(res.error)
// }
this._replaceAll(res.value)
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
}
_replaceAll(res: Post.Record) {
this.text = res.text
this.entities = res.entities
this.reply = res.reply
this.createdAt = res.createdAt
}
}

View File

@ -10,6 +10,12 @@ import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {FollowState} from '../cache/my-follows' import {FollowState} from '../cache/my-follows'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types'
import {
getProfileModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
@ -75,6 +81,18 @@ export class ProfileModel {
return this.hasLoaded && !this.hasContent return this.hasLoaded && !this.hasContent
} }
get labelInfo(): ProfileLabelInfo {
return {
accountLabels: filterAccountLabels(this.labels),
profileLabels: filterProfileLabels(this.labels),
isMuted: this.viewer?.muted || false,
}
}
get moderation(): ProfileModeration {
return getProfileModeration(this.rootStore, this.labelInfo)
}
// public api // public api
// = // =

View File

@ -1,88 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {PostsFeedItemModel} from '../feeds/posts'
import {cleanError} from 'lib/strings/errors'
import {TEAM_HANDLES} from 'lib/constants'
import {
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
export class SuggestedPostsModel {
// state
isLoading = false
hasLoaded = false
error = ''
// data
posts: PostsFeedItemModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.posts.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
this._xLoading()
try {
const responses = await getMultipleAuthorsPosts(
this.rootStore,
TEAM_HANDLES(String(this.rootStore.agent.service)),
undefined,
30,
)
runInAction(() => {
const finalPosts = mergePosts(responses, {repostsOnly: true})
// hydrate into models
this.posts = finalPosts.map((post, i) => {
// strip the reasons to hide that these are reposts
delete post.reason
return new PostsFeedItemModel(this.rootStore, `post-${i}`, post)
})
})
this._xIdle()
} catch (e: any) {
this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
e,
})
this._xIdle() // dont bubble to the user
}
}
// state transitions
// =
_xLoading() {
this.isLoading = true
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch suggested posts', err)
}
}
}

View File

@ -15,6 +15,16 @@ import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {PostThreadModel} from '../content/post-thread' import {PostThreadModel} from '../content/post-thread'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {
PostLabelInfo,
PostModeration,
ModerationBehaviorCode,
} from 'lib/labeling/types'
import {
getPostModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
const GROUPABLE_REASONS = ['like', 'repost', 'follow'] const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -90,6 +100,24 @@ export class NotificationsFeedItemModel {
} }
} }
get labelInfo(): PostLabelInfo {
const addedInfo = this.additionalPost?.thread?.labelInfo
return {
postLabels: (this.labels || []).concat(addedInfo?.postLabels || []),
accountLabels: filterAccountLabels(this.author.labels).concat(
addedInfo?.accountLabels || [],
),
profileLabels: filterProfileLabels(this.author.labels).concat(
addedInfo?.profileLabels || [],
),
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
get numUnreadInGroup(): number { get numUnreadInGroup(): number {
if (this.additional?.length) { if (this.additional?.length) {
return ( return (
@ -520,16 +548,22 @@ export class NotificationsFeedModel {
_filterNotifications( _filterNotifications(
items: NotificationsFeedItemModel[], items: NotificationsFeedItemModel[],
): NotificationsFeedItemModel[] { ): NotificationsFeedItemModel[] {
return items.filter(item => { return items
const hideByLabel = .filter(item => {
this.rootStore.preferences.getLabelPreference(item.labels).pref === const hideByLabel =
'hide' item.moderation.list.behavior === ModerationBehaviorCode.Hide
let mutedThread = !!( let mutedThread = !!(
item.reasonSubjectRootUri && item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
) )
return !hideByLabel && !mutedThread return !hideByLabel && !mutedThread
}) })
.map(item => {
if (item.additional?.length) {
item.additional = this._filterNotifications(item.additional)
}
return item
})
} }
async _fetchItemModels( async _fetchItemModels(

View File

@ -20,6 +20,13 @@ import {
} from 'lib/api/build-suggested-posts' } from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
import {updateDataOptimistically} from 'lib/async/revertible' import {updateDataOptimistically} from 'lib/async/revertible'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
getPostModeration,
filterAccountLabels,
filterProfileLabels,
} from 'lib/labeling/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@ -83,6 +90,21 @@ export class PostsFeedItemModel {
return this.rootStore.mutedThreads.uris.has(this.rootUri) return this.rootStore.mutedThreads.uris.has(this.rootUri)
} }
get labelInfo(): PostLabelInfo {
return {
postLabels: (this.post.labels || []).concat(
getEmbedLabels(this.post.embed),
),
accountLabels: filterAccountLabels(this.post.author.labels),
profileLabels: filterProfileLabels(this.post.author.labels),
isMuted: this.post.author.viewer?.muted || false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
copy(v: FeedViewPost) { copy(v: FeedViewPost) {
this.post = v.post this.post = v.post
this.reply = v.reply this.reply = v.reply

View File

@ -1,66 +0,0 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedPostsModel} from 'state/models/discovery/suggested-posts'
import {s} from 'lib/styles'
import {FeedItem as Post} from '../posts/FeedItem'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedPosts = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedPostsView = React.useMemo<SuggestedPostsModel>(
() => new SuggestedPostsModel(store),
[store],
)
React.useEffect(() => {
if (!suggestedPostsView.hasLoaded) {
suggestedPostsView.setup()
}
}, [store, suggestedPostsView])
return (
<>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Recently, on Bluesky...
</Text>
)}
{suggestedPostsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedPostsView.posts.map(item => (
<Post item={item} key={item._reactKey} showFollowBtn />
))}
</View>
</>
)}
{suggestedPostsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
})

View File

@ -8,7 +8,7 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {AtUri, ComAtprotoLabelDefs} from '@atproto/api' import {AtUri} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -26,8 +26,14 @@ import {UserAvatar} from '../util/UserAvatar'
import {ImageHorzList} from '../util/images/ImageHorzList' import {ImageHorzList} from '../util/images/ImageHorzList'
import {Post} from '../post/Post' import {Post} from '../post/Post'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {
getProfileViewBasicLabelInfo,
getProfileModeration,
} from 'lib/labeling/helpers'
import {ProfileModeration} from 'lib/labeling/types'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@ -38,14 +44,15 @@ interface Author {
handle: string handle: string
displayName?: string displayName?: string
avatar?: string avatar?: string
labels?: ComAtprotoLabelDefs.Label[] moderation: ProfileModeration
} }
export const FeedItem = observer(function FeedItem({ export const FeedItem = observer(function ({
item, item,
}: { }: {
item: NotificationsFeedItemModel item: NotificationsFeedItemModel
}) { }) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => { const itemHref = useMemo(() => {
@ -81,27 +88,25 @@ export const FeedItem = observer(function FeedItem({
handle: item.author.handle, handle: item.author.handle,
displayName: item.author.displayName, displayName: item.author.displayName,
avatar: item.author.avatar, avatar: item.author.avatar,
labels: item.author.labels, moderation: getProfileModeration(
store,
getProfileViewBasicLabelInfo(item.author),
),
}, },
...(item.additional?.map( ...(item.additional?.map(({author}) => {
({author: {avatar, labels, handle, displayName}}) => { return {
return { href: `/profile/${author.handle}`,
href: `/profile/${handle}`, handle: author.handle,
handle, displayName: author.displayName,
displayName, avatar: author.avatar,
avatar, moderation: getProfileModeration(
labels, store,
} getProfileViewBasicLabelInfo(author),
}, ),
) || []), }
}) || []),
] ]
}, [ }, [store, item.additional, item.author])
item.additional,
item.author.avatar,
item.author.displayName,
item.author.handle,
item.author.labels,
])
if (item.additionalPost?.notFound) { if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable // don't render anything if the target post was deleted or unfindable
@ -264,7 +269,7 @@ function CondensedAuthorsList({
<UserAvatar <UserAvatar
size={35} size={35}
avatar={authors[0].avatar} avatar={authors[0].avatar}
hasWarning={!!authors[0].labels?.length} moderation={authors[0].moderation.avatar}
/> />
</Link> </Link>
</View> </View>
@ -277,7 +282,7 @@ function CondensedAuthorsList({
<UserAvatar <UserAvatar
size={35} size={35}
avatar={author.avatar} avatar={author.avatar}
hasWarning={!!author.labels?.length} moderation={author.moderation.avatar}
/> />
</View> </View>
))} ))}
@ -335,7 +340,7 @@ function ExpandedAuthorsList({
<UserAvatar <UserAvatar
size={35} size={35}
avatar={author.avatar} avatar={author.avatar}
hasWarning={!!author.labels?.length} moderation={author.moderation.avatar}
/> />
</View> </View>
<View style={s.flex1}> <View style={s.flex1}>

View File

@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
// loaded // loaded
// = // =
const renderItem = ({item}: {item: LikeItem}) => ( const renderItem = ({item}: {item: LikeItem}) => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
key={item.actor.did}
did={item.actor.did}
handle={item.actor.handle}
displayName={item.actor.displayName}
avatar={item.actor.avatar}
labels={item.actor.labels}
isFollowedBy={!!item.actor.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList

View File

@ -58,15 +58,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: RepostedByItem}) => ( const renderItem = ({item}: {item: RepostedByItem}) => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn key={item.did} profile={item} />
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList

View File

@ -145,21 +145,17 @@ export const PostThreadItem = observer(function PostThreadItem({
if (item._isHighlightedPost) { if (item._isHighlightedPost) {
return ( return (
<View <PostHider
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
style={[ style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
styles.outer, moderation={item.moderation.thread}>
styles.outerHighlighted,
{borderTopColor: pal.colors.border},
pal.view,
]}>
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle} asAnchor> <Link href={authorHref} title={authorTitle} asAnchor>
<UserAvatar <UserAvatar
size={52} size={52}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length} moderation={item.moderation.avatar}
/> />
</Link> </Link>
</View> </View>
@ -218,9 +214,7 @@ export const PostThreadItem = observer(function PostThreadItem({
</View> </View>
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider <ContentHider moderation={item.moderation.view}>
isMuted={item.post.author.viewer?.muted === true}
labels={item.post.labels}>
{item.richText?.text ? ( {item.richText?.text ? (
<View <View
style={[ style={[
@ -300,7 +294,7 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
</View> </View>
</View> </PostHider>
) )
} else { } else {
return ( return (
@ -309,8 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
href={itemHref} href={itemHref}
style={[styles.outer, {borderColor: pal.colors.border}, pal.view]} style={[styles.outer, {borderColor: pal.colors.border}, pal.view]}
isMuted={item.post.author.viewer?.muted === true} moderation={item.moderation.thread}>
labels={item.post.labels}>
{item._showParentReplyLine && ( {item._showParentReplyLine && (
<View <View
style={[ style={[
@ -333,7 +326,7 @@ export const PostThreadItem = observer(function PostThreadItem({
<UserAvatar <UserAvatar
size={52} size={52}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length} moderation={item.moderation.avatar}
/> />
</Link> </Link>
</View> </View>
@ -347,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({
did={item.post.author.did} did={item.post.author.did}
/> />
<ContentHider <ContentHider
labels={item.post.labels} moderation={item.moderation.thread}
containerStyle={styles.contentHider}> containerStyle={styles.contentHider}>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>

View File

@ -206,8 +206,7 @@ const PostLoaded = observer(
<PostHider <PostHider
href={itemHref} href={itemHref}
style={[styles.outer, pal.view, pal.border, style]} style={[styles.outer, pal.view, pal.border, style]}
isMuted={item.post.author.viewer?.muted === true} moderation={item.moderation.list}>
labels={item.post.labels}>
{showReplyLine && <View style={styles.replyLine} />} {showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
@ -215,7 +214,7 @@ const PostLoaded = observer(
<UserAvatar <UserAvatar
size={52} size={52}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length} moderation={item.moderation.avatar}
/> />
</Link> </Link>
</View> </View>
@ -247,7 +246,7 @@ const PostLoaded = observer(
</View> </View>
)} )}
<ContentHider <ContentHider
labels={item.post.labels} moderation={item.moderation.list}
containerStyle={styles.contentHider}> containerStyle={styles.contentHider}>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>

View File

@ -1,62 +0,0 @@
import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {PostModel} from 'state/models/content/post'
import {useStores} from 'state/index'
export const PostText = observer(function PostText({
uri,
style,
}: {
uri: string
style?: StyleProp<TextStyle>
}) {
const store = useStores()
const [model, setModel] = useState<PostModel | undefined>()
useEffect(() => {
if (model?.uri === uri) {
return // no change needed? or trigger refresh?
}
const newModel = new PostModel(store, uri)
setModel(newModel)
newModel.setup().catch(err => store.log.error('Failed to fetch post', err))
}, [uri, model?.uri, store])
// loading
// =
if (!model || model.isLoading || model.uri !== uri) {
return (
<View>
<LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
<LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
<LoadingPlaceholder width={100} height={8} style={styles.mt6} />
</View>
)
}
// error
// =
if (model.hasError) {
return (
<View>
<ErrorMessage style={style} message={model.error} />
</View>
)
}
// loaded
// =
return (
<View>
<Text style={style}>{model.text}</Text>
</View>
)
})
const styles = StyleSheet.create({
mt6: {marginTop: 6},
})

View File

@ -30,14 +30,13 @@ export const FeedItem = observer(function ({
isThreadChild, isThreadChild,
isThreadParent, isThreadParent,
showFollowBtn, showFollowBtn,
ignoreMuteFor,
}: { }: {
item: PostsFeedItemModel item: PostsFeedItemModel
isThreadChild?: boolean isThreadChild?: boolean
isThreadParent?: boolean isThreadParent?: boolean
showReplyLine?: boolean showReplyLine?: boolean
showFollowBtn?: boolean showFollowBtn?: boolean
ignoreMuteFor?: string ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
}) { }) {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
@ -134,8 +133,6 @@ export const FeedItem = observer(function ({
} }
const isSmallTop = isThreadChild const isSmallTop = isThreadChild
const isMuted =
item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
const outerStyles = [ const outerStyles = [
styles.outer, styles.outer,
pal.view, pal.view,
@ -149,8 +146,7 @@ export const FeedItem = observer(function ({
testID={`feedItem-by-${item.post.author.handle}`} testID={`feedItem-by-${item.post.author.handle}`}
style={outerStyles} style={outerStyles}
href={itemHref} href={itemHref}
isMuted={isMuted} moderation={item.moderation.list}>
labels={item.post.labels}>
{isThreadChild && ( {isThreadChild && (
<View <View
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@ -200,7 +196,7 @@ export const FeedItem = observer(function ({
<UserAvatar <UserAvatar
size={52} size={52}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
hasWarning={!!item.post.author.labels?.length} moderation={item.moderation.avatar}
/> />
</Link> </Link>
</View> </View>
@ -236,7 +232,7 @@ export const FeedItem = observer(function ({
</View> </View>
)} )}
<ContentHider <ContentHider
labels={item.post.labels} moderation={item.moderation.list}
containerStyle={styles.contentHider}> containerStyle={styles.contentHider}>
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@ -10,143 +10,159 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {FollowButton} from './FollowButton' import {FollowButton} from './FollowButton'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {
getProfileViewBasicLabelInfo,
getProfileModeration,
} from 'lib/labeling/helpers'
import {ModerationBehaviorCode} from 'lib/labeling/types'
export function ProfileCard({ export const ProfileCard = observer(
testID, ({
handle, testID,
displayName, profile,
avatar, noBg,
description, noBorder,
labels, followers,
isFollowedBy, renderButton,
noBg, }: {
noBorder, testID?: string
followers, profile: AppBskyActorDefs.ProfileViewBasic
renderButton, noBg?: boolean
}: { noBorder?: boolean
testID?: string followers?: AppBskyActorDefs.ProfileView[] | undefined
handle: string renderButton?: () => JSX.Element
displayName?: string }) => {
avatar?: string const store = useStores()
description?: string const pal = usePalette('default')
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean const moderation = getProfileModeration(
noBg?: boolean store,
noBorder?: boolean getProfileViewBasicLabelInfo(profile),
followers?: AppBskyActorDefs.ProfileView[] | undefined )
renderButton?: () => JSX.Element
}) { if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
const pal = usePalette('default') return null
return ( }
<Link
testID={testID} return (
style={[ <Link
styles.outer, testID={testID}
pal.border, style={[
noBorder && styles.outerNoBorder, styles.outer,
!noBg && pal.view, pal.border,
]} noBorder && styles.outerNoBorder,
href={`/profile/${handle}`} !noBg && pal.view,
title={handle} ]}
asAnchor> href={`/profile/${profile.handle}`}
<View style={styles.layout}> title={profile.handle}
<View style={styles.layoutAvi}> asAnchor>
<UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} /> <View style={styles.layout}>
</View> <View style={styles.layoutAvi}>
<View style={styles.layoutContent}> <UserAvatar
<Text size={40}
type="lg" avatar={profile.avatar}
style={[s.bold, pal.text]} moderation={moderation.avatar}
numberOfLines={1} />
lineHeight={1.2}> </View>
{sanitizeDisplayName(displayName || handle)} <View style={styles.layoutContent}>
</Text> <Text
<Text type="md" style={[pal.textLight]} numberOfLines={1}> type="lg"
@{handle} style={[s.bold, pal.text]}
</Text> numberOfLines={1}
{isFollowedBy && ( lineHeight={1.2}>
<View style={s.flexRow}> {sanitizeDisplayName(profile.displayName || profile.handle)}
<View style={[s.mt5, pal.btn, styles.pill]}> </Text>
<Text type="xs" style={pal.text}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
Follows You @{profile.handle}
</Text> </Text>
{!!profile.viewer?.followedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Follows You
</Text>
</View>
</View> </View>
</View> )}
)} </View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View> </View>
{renderButton ? ( {profile.description ? (
<View style={styles.layoutButton}>{renderButton()}</View> <View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{profile.description}
</Text>
</View>
) : undefined} ) : undefined}
</View> <FollowersList followers={followers} />
{description ? ( </Link>
<View style={styles.details}> )
<Text style={pal.text} numberOfLines={4}> },
{description} )
</Text>
</View> const FollowersList = observer(
) : undefined} ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
{followers?.length ? ( const store = useStores()
<View style={styles.followedBy}> const pal = usePalette('default')
<Text if (!followers?.length) {
type="sm" return null
style={[styles.followsByDesc, pal.textLight]} }
numberOfLines={2}
lineHeight={1.2}> const followersWithMods = followers
Followed by{' '} .map(f => ({
{followers.map(f => f.displayName || f.handle).join(', ')} f,
</Text> mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
{followers.slice(0, 3).map(f => ( }))
<View key={f.did} style={styles.followedByAviContainer}> .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} /> return (
</View> <View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View> </View>
))} </View>
</View> ))}
) : undefined} </View>
</Link> )
) },
} )
export const ProfileCardWithFollowBtn = observer( export const ProfileCardWithFollowBtn = observer(
({ ({
did, profile,
handle,
displayName,
avatar,
description,
labels,
isFollowedBy,
noBg, noBg,
noBorder, noBorder,
followers, followers,
}: { }: {
did: string profile: AppBskyActorDefs.ProfileViewBasic
handle: string
displayName?: string
avatar?: string
description?: string
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean
noBg?: boolean noBg?: boolean
noBorder?: boolean noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => { }) => {
const store = useStores() const store = useStores()
const isMe = store.me.handle === handle const isMe = store.me.handle === profile.handle
return ( return (
<ProfileCard <ProfileCard
handle={handle} profile={profile}
displayName={displayName}
avatar={avatar}
description={description}
labels={labels}
isFollowedBy={isFollowedBy}
noBg={noBg} noBg={noBg}
noBorder={noBorder} noBorder={noBorder}
followers={followers} followers={followers}
renderButton={isMe ? undefined : () => <FollowButton did={did} />} renderButton={
isMe ? undefined : () => <FollowButton did={profile.did} />
}
/> />
) )
}, },

View File

@ -61,15 +61,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowerItem}) => ( const renderItem = ({item}: {item: FollowerItem}) => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn key={item.did} profile={item} />
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList

View File

@ -58,15 +58,7 @@ export const ProfileFollows = observer(function ProfileFollows({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowItem}) => ( const renderItem = ({item}: {item: FollowItem}) => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn key={item.did} profile={item} />
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList

View File

@ -26,7 +26,7 @@ import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels' import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
@ -219,7 +219,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
]) ])
return ( return (
<View style={pal.view}> <View style={pal.view}>
<UserBanner banner={view.banner} /> <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
<View style={styles.content}> <View style={styles.content}>
<View style={[styles.buttonsLine]}> <View style={[styles.buttonsLine]}>
{isMe ? ( {isMe ? (
@ -332,7 +332,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
richText={view.descriptionRichText} richText={view.descriptionRichText}
/> />
) : undefined} ) : undefined}
<ProfileHeaderLabels labels={view.labels} /> <ProfileHeaderWarnings moderation={view.moderation.view} />
{view.viewer.muted ? ( {view.viewer.muted ? (
<View <View
testID="profileHeaderMutedNotice" testID="profileHeaderMutedNotice"
@ -364,7 +364,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
<UserAvatar <UserAvatar
size={80} size={80}
avatar={view.avatar} avatar={view.avatar}
hasWarning={!!view.labels?.length} moderation={view.moderation.avatar}
/> />
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>

View File

@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
return ( return (
<ScrollView style={pal.view}> <ScrollView style={pal.view}>
{model.profiles.map(item => ( {model.profiles.map(item => (
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn key={item.did} profile={item} />
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
labels={item.labels}
/>
))} ))}
<View style={s.footerSpacer} /> <View style={s.footerSpacer} />
<View style={s.footerSpacer} /> <View style={s.footerSpacer} />

View File

@ -144,18 +144,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}> <View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.ref.did} key={item.ref.did}
did={item.ref.did} profile={item.ref}
handle={item.ref.handle}
displayName={item.ref.displayName}
avatar={item.ref.avatar}
labels={item.ref.labels}
noBg noBg
noBorder noBorder
description={
item.ref.description
? (item.ref as AppBskyActorDefs.ProfileView).description
: ''
}
followers={ followers={
item.ref.followers item.ref.followers
? (item.ref.followers as AppBskyActorDefs.ProfileView[]) ? (item.ref.followers as AppBskyActorDefs.ProfileView[])
@ -170,18 +161,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}> <View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.view.did} key={item.view.did}
did={item.view.did} profile={item.view}
handle={item.view.handle}
displayName={item.view.displayName}
avatar={item.view.avatar}
labels={item.view.labels}
noBg noBg
noBorder noBorder
description={
item.view.description
? (item.view as AppBskyActorDefs.ProfileView).description
: ''
}
/> />
</View> </View>
) )
@ -191,19 +173,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}> <View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn <ProfileCardWithFollowBtn
key={item.suggested.did} key={item.suggested.did}
did={item.suggested.did} profile={item.suggested}
handle={item.suggested.handle}
displayName={item.suggested.displayName}
avatar={item.suggested.avatar}
labels={item.suggested.labels}
noBg noBg
noBorder noBorder
description={
item.suggested.description
? (item.suggested as AppBskyActorDefs.ProfileView)
.description
: ''
}
/> />
</View> </View>
) )

View File

@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<UserAvatar <UserAvatar
avatar={opts.authorAvatar} avatar={opts.authorAvatar}
size={16} size={16}
hasWarning={opts.authorHasWarning} // TODO moderation
/> />
</View> </View>
)} )}

View File

@ -13,8 +13,11 @@ import {useStores} from 'state/index'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton' import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection' import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {AvatarModeration} from 'lib/labeling/types'
const BLUR_AMOUNT = isWeb ? 5 : 100
function DefaultAvatar({size}: {size: number}) { function DefaultAvatar({size}: {size: number}) {
return ( return (
@ -40,12 +43,12 @@ function DefaultAvatar({size}: {size: number}) {
export function UserAvatar({ export function UserAvatar({
size, size,
avatar, avatar,
hasWarning, moderation,
onSelectNewAvatar, onSelectNewAvatar,
}: { }: {
size: number size: number
avatar?: string | null avatar?: string | null
hasWarning?: boolean moderation?: AvatarModeration
onSelectNewAvatar?: (img: RNImage | null) => void onSelectNewAvatar?: (img: RNImage | null) => void
}) { }) {
const store = useStores() const store = useStores()
@ -114,7 +117,7 @@ export function UserAvatar({
) )
const warning = useMemo(() => { const warning = useMemo(() => {
if (!hasWarning) { if (!moderation?.warn) {
return null return null
} }
return ( return (
@ -126,7 +129,7 @@ export function UserAvatar({
/> />
</View> </View>
) )
}, [hasWarning, size, pal]) }, [moderation?.warn, size, pal])
// onSelectNewAvatar is only passed as prop on the EditProfile component // onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? ( return onSelectNewAvatar ? (
@ -159,13 +162,15 @@ export function UserAvatar({
/> />
</View> </View>
</DropdownButton> </DropdownButton>
) : avatar ? ( ) : avatar &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<View style={{width: size, height: size}}> <View style={{width: size, height: size}}>
<HighPriorityImage <HighPriorityImage
testID="userAvatarImage" testID="userAvatarImage"
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
contentFit="cover" contentFit="cover"
source={{uri: avatar}} source={{uri: avatar}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/> />
{warning} {warning}
</View> </View>

View File

@ -13,13 +13,16 @@ import {
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {DropdownButton} from './forms/DropdownButton' import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection' import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection'
export function UserBanner({ export function UserBanner({
banner, banner,
moderation,
onSelectNewBanner, onSelectNewBanner,
}: { }: {
banner?: string | null banner?: string | null
moderation?: AvatarModeration
onSelectNewBanner?: (img: TImage | null) => void onSelectNewBanner?: (img: TImage | null) => void
}) { }) {
const store = useStores() const store = useStores()
@ -107,12 +110,14 @@ export function UserBanner({
/> />
</View> </View>
</DropdownButton> </DropdownButton>
) : banner ? ( ) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image <Image
testID="userBannerImage" testID="userBannerImage"
style={styles.bannerImage} style={styles.bannerImage}
resizeMode="cover" resizeMode="cover"
source={{uri: banner}} source={{uri: banner}}
blurRadius={moderation?.blur ? 100 : 0}
/> />
) : ( ) : (
<View <View

View File

@ -35,7 +35,7 @@ export function ErrorScreen({
]}> ]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="exclamation" icon="exclamation"
style={pal.textInverted} style={pal.textInverted as FontAwesomeIconStyle}
size={24} size={24}
/> />
</View> </View>

View File

@ -6,32 +6,31 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
export function ContentHider({ export function ContentHider({
testID, testID,
isMuted, moderation,
labels,
style, style,
containerStyle, containerStyle,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
testID?: string testID?: string
isMuted?: boolean moderation: ModerationBehavior
labels: ComAtprotoLabelDefs.Label[] | undefined
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle>
}>) { }>) {
const pal = usePalette('default') const pal = usePalette('default')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const store = useStores()
const labelPref = store.preferences.getLabelPreference(labels)
if (!isMuted && labelPref.pref === 'show') { if (
moderation.behavior === ModerationBehaviorCode.Show ||
moderation.behavior === ModerationBehaviorCode.Warn ||
moderation.behavior === ModerationBehaviorCode.WarnImages
) {
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{children} {children}
@ -39,7 +38,7 @@ export function ContentHider({
) )
} }
if (labelPref.pref === 'hide') { if (moderation.behavior === ModerationBehaviorCode.Hide) {
return null return null
} }
@ -52,11 +51,7 @@ export function ContentHider({
override && styles.descriptionOpen, override && styles.descriptionOpen,
]}> ]}>
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
{isMuted ? ( {moderation.reason || 'Content warning'}
<>Post from an account you muted.</>
) : (
<>Warning: {labelPref.desc.warning || labelPref.desc.title}</>
)}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.showBtn} style={styles.showBtn}

View File

@ -6,77 +6,72 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {Link} from '../Link' import {Link} from '../Link'
import {Text} from '../text/Text' import {Text} from '../text/Text'
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
import {useStores} from 'state/index' import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function PostHider({ export function PostHider({
testID, testID,
href, href,
isMuted, moderation,
labels,
style, style,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
testID?: string testID?: string
href: string href?: string
isMuted: boolean | undefined moderation: ModerationBehavior
labels: ComAtprotoLabelDefs.Label[] | undefined
style: StyleProp<ViewStyle> style: StyleProp<ViewStyle>
}>) { }>) {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const bg = override ? pal.viewLight : pal.view const bg = override ? pal.viewLight : pal.view
const labelPref = store.preferences.getLabelPreference(labels) if (moderation.behavior === ModerationBehaviorCode.Hide) {
if (labelPref.pref === 'hide') { return null
return <></>
} }
if (!isMuted) { if (moderation.behavior === ModerationBehaviorCode.Warn) {
// NOTE: any further label enforcement should occur in ContentContainer
return ( return (
<Link testID={testID} style={style} href={href} noFeedback> <>
{children} <View style={[styles.description, bg, pal.border]}>
</Link> <FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
{moderation.reason || 'Content warning'}
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
) )
} }
// NOTE: any further label enforcement should occur in ContentContainer
return ( return (
<> <Link testID={testID} style={style} href={href} noFeedback>
<View style={[styles.description, bg, pal.border]}> {children}
<FontAwesomeIcon </Link>
icon={['far', 'eye-slash']}
style={[styles.icon, pal.text]}
/>
<Text type="md" style={pal.textLight}>
Post from an account you muted.
</Text>
<TouchableOpacity
style={styles.showBtn}
onPress={() => setOverride(v => !v)}>
<Text type="md" style={pal.link}>
{override ? 'Hide' : 'Show'} post
</Text>
</TouchableOpacity>
</View>
{override && (
<View style={[styles.childrenContainer, pal.border, bg]}>
<Link
testID={testID}
style={addStyle(style, styles.child)}
href={href}
noFeedback>
{children}
</Link>
</View>
)}
</>
) )
} }

View File

@ -1,55 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {getLabelValueGroup} from 'lib/labeling/helpers'
export function ProfileHeaderLabels({
labels,
}: {
labels: ComAtprotoLabelDefs.Label[] | undefined
}) {
const palErr = usePalette('error')
if (!labels?.length) {
return null
}
return (
<>
{labels.map((label, i) => {
const labelGroup = getLabelValueGroup(label?.val || '')
return (
<View
key={`${label.val}-${i}`}
style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged for{' '}
{(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}.
</Text>
</View>
)
})}
</>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View File

@ -0,0 +1,44 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
export function ProfileHeaderWarnings({
moderation,
}: {
moderation: ModerationBehavior
}) {
const palErr = usePalette('error')
if (moderation.behavior === ModerationBehaviorCode.Show) {
return null
}
return (
<View style={[styles.container, palErr.border, palErr.view]}>
<FontAwesomeIcon
icon="circle-exclamation"
style={palErr.text as FontAwesomeIconStyle}
size={20}
/>
<Text style={palErr.text}>
This account has been flagged: {moderation.reason}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
},
})

View File

@ -0,0 +1,129 @@
import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
import {NavigationProp} from 'lib/routes/types'
import {Text} from '../text/Text'
import {Button} from '../forms/Button'
import {isDesktopWeb} from 'platform/detection'
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
export function ScreenHider({
testID,
screenDescription,
moderation,
style,
containerStyle,
children,
}: React.PropsWithChildren<{
testID?: string
screenDescription: string
moderation: ModerationBehavior
style?: StyleProp<ViewStyle>
containerStyle?: StyleProp<ViewStyle>
}>) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const [override, setOverride] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
return (
<View testID={testID} style={style}>
{children}
</View>
)
}
return (
<View style={[styles.container, pal.view, containerStyle]}>
<View style={styles.iconContainer}>
<View style={[styles.icon, palInverted.view]}>
<FontAwesomeIcon
icon="exclamation"
style={pal.textInverted as FontAwesomeIconStyle}
size={24}
/>
</View>
</View>
<Text type="title-2xl" style={[styles.title, pal.text]}>
Content Warning
</Text>
<Text type="2xl" style={[styles.description, pal.textLight]}>
This {screenDescription} has been flagged:{' '}
{moderation.reason || 'Content warning'}
</Text>
{!isDesktopWeb && <View style={styles.spacer} />}
<View style={styles.btnContainer}>
<Button type="inverted" onPress={onPressBack} style={styles.btn}>
<Text type="button-lg" style={pal.textInverted}>
Go back
</Text>
</Button>
{!moderation.noOverride && (
<Button
type="default"
onPress={() => setOverride(v => !v)}
style={styles.btn}>
<Text type="button-lg" style={pal.text}>
Show anyway
</Text>
</Button>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
spacer: {
flex: 1,
},
container: {
flex: 1,
paddingTop: 100,
paddingBottom: 150,
},
iconContainer: {
alignItems: 'center',
marginBottom: 10,
},
icon: {
borderRadius: 25,
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
title: {
textAlign: 'center',
marginBottom: 10,
},
description: {
marginBottom: 10,
paddingHorizontal: 20,
textAlign: 'center',
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginVertical: 10,
gap: 10,
},
btn: {
paddingHorizontal: 20,
paddingVertical: 14,
},
})

View File

@ -6,6 +6,7 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewSelector} from '../com/util/ViewSelector' import {ViewSelector} from '../com/util/ViewSelector'
import {CenteredView} from '../com/util/Views' import {CenteredView} from '../com/util/Views'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {ProfileUiModel} from 'state/models/ui/profile' import {ProfileUiModel} from 'state/models/ui/profile'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {PostsFeedSliceModel} from 'state/models/feeds/posts'
@ -140,7 +141,11 @@ export const ProfileScreen = withAuthRequired(
) )
return ( return (
<View testID="profileView" style={styles.container}> <ScreenHider
testID="profileView"
style={styles.container}
screenDescription="profile"
moderation={uiState.profile.moderation.view}>
{uiState.profile.hasError ? ( {uiState.profile.hasError ? (
<ErrorScreen <ErrorScreen
testID="profileErrorScreen" testID="profileErrorScreen"
@ -169,7 +174,7 @@ export const ProfileScreen = withAuthRequired(
onPress={onPressCompose} onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
/> />
</View> </ScreenHider>
) )
}), }),
) )

View File

@ -146,19 +146,14 @@ export const SearchScreen = withAuthRequired(
scrollEventThrottle={100}> scrollEventThrottle={100}>
{query && autocompleteView.searchRes.length ? ( {query && autocompleteView.searchRes.length ? (
<> <>
{autocompleteView.searchRes.map( {autocompleteView.searchRes.map((profile, index) => (
({did, handle, displayName, labels, avatar}, index) => ( <ProfileCard
<ProfileCard key={profile.did}
key={did} testID={`searchAutoCompleteResult-${profile.handle}`}
testID={`searchAutoCompleteResult-${handle}`} profile={profile}
handle={handle} noBorder={index === 0}
displayName={displayName} />
labels={labels} ))}
avatar={avatar}
noBorder={index === 0}
/>
),
)}
</> </>
) : query && !autocompleteView.searchRes.length ? ( ) : query && !autocompleteView.searchRes.length ? (
<View> <View>

View File

@ -85,14 +85,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
{autocompleteView.searchRes.length ? ( {autocompleteView.searchRes.length ? (
<> <>
{autocompleteView.searchRes.map((item, i) => ( {autocompleteView.searchRes.map((item, i) => (
<ProfileCard <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
key={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
noBorder={i === 0}
/>
))} ))}
</> </>
) : ( ) : (

View File

@ -30,10 +30,10 @@
tlds "^1.234.0" tlds "^1.234.0"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
"@atproto/api@0.2.9": "@atproto/api@0.2.10":
version "0.2.9" version "0.2.10"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.9.tgz#08e29da66d1a9001d9d3ce427548c1760d805e99" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.10.tgz#19c4d695f88ab4e45e4c9f2f4db5fad61590a3d2"
integrity sha512-r00IqidX2YF3VUEa4MUO2Vxqp3+QhI1cSNcWgzT4LsANapzrwdDTM+rY2Ejp9na3F+unO4SWRW3o434cVmG5gw== integrity sha512-97UBtvIXhsgNO7bXhHk0JwDNwyqTcL1N0JT2rnXjUeLKNf2hDvomFtI50Y4RFU942uUS5W5VtM+JJuZO5Ryw5w==
dependencies: dependencies:
"@atproto/common-web" "*" "@atproto/common-web" "*"
"@atproto/uri" "*" "@atproto/uri" "*"