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.24zio/stable
parent
51be8474db
commit
1d50ddb378
|
@ -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)
|
||||||
|
|
4
app.json
4
app.json
|
@ -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"
|
||||||
|
|
149
jest/test-pds.ts
149
jest/test-pds.ts
|
@ -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) =>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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},
|
|
||||||
})
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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" "*"
|
||||||
|
|
Loading…
Reference in New Issue