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')
return res.writeHead(200).end(server.pdsUrl)

View File

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

View File

@ -2,6 +2,7 @@ import {AddressInfo} from 'net'
import os from 'os'
import net from 'net'
import path from 'path'
import fs from 'fs'
import * as crypto from '@atproto/crypto'
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
import * as plc from '@did-plc/lib'
@ -104,9 +105,13 @@ export async function createServer(
await pds.start()
const pdsUrl = `http://localhost:${port}`
const profilePic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
)
return {
pdsUrl,
mocker: new Mocker(pdsUrl),
mocker: new Mocker(pds, pdsUrl, profilePic),
async close() {
await pds.destroy()
await plcServer.destroy()
@ -118,7 +123,11 @@ class Mocker {
agent: BskyAgent
users: Record<string, TestUser> = {}
constructor(public service: string) {
constructor(
public pds: PDS,
public service: string,
public profilePic: Uint8Array,
) {
this.agent = new BskyAgent({service})
}
@ -152,6 +161,15 @@ class Mocker {
handle: name + '.test',
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] = {
did: res.data.did,
email,
@ -192,6 +210,133 @@ class Mocker {
await this.follow('carla', 'alice')
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) =>

View File

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

View File

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

View File

@ -1,9 +1,33 @@
import {
LabelValGroup,
AppBskyActorDefs,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
AppBskyFeedPost,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
import {
CONFIGURABLE_LABEL_GROUPS,
ILLEGAL_LABEL_GROUP,
UNKNOWN_LABEL_GROUP,
} 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 {
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
@ -17,3 +41,280 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
}
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 {cleanError} from 'lib/strings/errors'
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 {
// ui state
@ -46,6 +53,21 @@ export class PostThreadItemModel {
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(
public rootStore: RootStoreModel,
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 {FollowState} from '../cache/my-follows'
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'
@ -75,6 +81,18 @@ export class ProfileModel {
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
// =

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 {PostThreadModel} from '../content/post-thread'
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 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 {
if (this.additional?.length) {
return (
@ -520,16 +548,22 @@ export class NotificationsFeedModel {
_filterNotifications(
items: NotificationsFeedItemModel[],
): NotificationsFeedItemModel[] {
return items.filter(item => {
return items
.filter(item => {
const hideByLabel =
this.rootStore.preferences.getLabelPreference(item.labels).pref ===
'hide'
item.moderation.list.behavior === ModerationBehaviorCode.Hide
let mutedThread = !!(
item.reasonSubjectRootUri &&
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
)
return !hideByLabel && !mutedThread
})
.map(item => {
if (item.additional?.length) {
item.additional = this._filterNotifications(item.additional)
}
return item
})
}
async _fetchItemModels(

View File

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

View File

@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
// loaded
// =
const renderItem = ({item}: {item: LikeItem}) => (
<ProfileCardWithFollowBtn
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}
/>
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
)
return (
<FlatList

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api'
import {AppBskyActorDefs} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
@ -10,33 +10,40 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {FollowButton} from './FollowButton'
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,
displayName,
avatar,
description,
labels,
isFollowedBy,
profile,
noBg,
noBorder,
followers,
renderButton,
}: {
}: {
testID?: string
handle: string
displayName?: string
avatar?: string
description?: string
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
renderButton?: () => JSX.Element
}) {
}) => {
const store = useStores()
const pal = usePalette('default')
const moderation = getProfileModeration(
store,
getProfileViewBasicLabelInfo(profile),
)
if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
return null
}
return (
<Link
testID={testID}
@ -46,12 +53,16 @@ export function ProfileCard({
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${handle}`}
title={handle}
href={`/profile/${profile.handle}`}
title={profile.handle}
asAnchor>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} />
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text
@ -59,12 +70,12 @@ export function ProfileCard({
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(displayName || handle)}
{sanitizeDisplayName(profile.displayName || profile.handle)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{handle}
@{profile.handle}
</Text>
{isFollowedBy && (
{!!profile.viewer?.followedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
@ -78,14 +89,35 @@ export function ProfileCard({
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View>
{description ? (
{profile.description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{description}
{profile.description}
</Text>
</View>
) : undefined}
{followers?.length ? (
<FollowersList followers={followers} />
</Link>
)
},
)
const FollowersList = observer(
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
const store = useStores()
const pal = usePalette('default')
if (!followers?.length) {
return null
}
const followersWithMods = followers
.map(f => ({
f,
mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
}))
.filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
return (
<View style={styles.followedBy}>
<Text
type="sm"
@ -93,60 +125,44 @@ export function ProfileCard({
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followers.map(f => f.displayName || f.handle).join(', ')}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Text>
{followers.slice(0, 3).map(f => (
{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} />
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
</View>
</View>
))}
</View>
) : undefined}
</Link>
)
}
},
)
export const ProfileCardWithFollowBtn = observer(
({
did,
handle,
displayName,
avatar,
description,
labels,
isFollowedBy,
profile,
noBg,
noBorder,
followers,
}: {
did: string
handle: string
displayName?: string
avatar?: string
description?: string
labels: ComAtprotoLabelDefs.Label[] | undefined
isFollowedBy?: boolean
profile: AppBskyActorDefs.ProfileViewBasic
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
}) => {
const store = useStores()
const isMe = store.me.handle === handle
const isMe = store.me.handle === profile.handle
return (
<ProfileCard
handle={handle}
displayName={displayName}
avatar={avatar}
description={description}
labels={labels}
isFollowedBy={isFollowedBy}
profile={profile}
noBg={noBg}
noBorder={noBorder}
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
// =
const renderItem = ({item}: {item: FollowerItem}) => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
labels={item.labels}
isFollowedBy={!!item.viewer?.followedBy}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
)
return (
<FlatList

View File

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

View File

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

View File

@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
return (
<ScrollView style={pal.view}>
{model.profiles.map(item => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
labels={item.labels}
/>
<ProfileCardWithFollowBtn key={item.did} profile={item} />
))}
<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]}>
<ProfileCardWithFollowBtn
key={item.ref.did}
did={item.ref.did}
handle={item.ref.handle}
displayName={item.ref.displayName}
avatar={item.ref.avatar}
labels={item.ref.labels}
profile={item.ref}
noBg
noBorder
description={
item.ref.description
? (item.ref as AppBskyActorDefs.ProfileView).description
: ''
}
followers={
item.ref.followers
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
@ -170,18 +161,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.view.did}
did={item.view.did}
handle={item.view.handle}
displayName={item.view.displayName}
avatar={item.view.avatar}
labels={item.view.labels}
profile={item.view}
noBg
noBorder
description={
item.view.description
? (item.view as AppBskyActorDefs.ProfileView).description
: ''
}
/>
</View>
)
@ -191,19 +173,9 @@ export const Suggestions = observer(
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.suggested.did}
did={item.suggested.did}
handle={item.suggested.handle}
displayName={item.suggested.displayName}
avatar={item.suggested.avatar}
labels={item.suggested.labels}
profile={item.suggested}
noBg
noBorder
description={
item.suggested.description
? (item.suggested as AppBskyActorDefs.ProfileView)
.description
: ''
}
/>
</View>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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