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')
|
||||
return res.writeHead(200).end(server.pdsUrl)
|
||||
|
|
4
app.json
4
app.json
|
@ -3,7 +3,7 @@
|
|||
"name": "Bluesky",
|
||||
"slug": "bluesky",
|
||||
"owner": "blueskysocial",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
|
@ -34,7 +34,7 @@
|
|||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 8,
|
||||
"versionCode": 9,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
|
|
149
jest/test-pds.ts
149
jest/test-pds.ts
|
@ -2,6 +2,7 @@ import {AddressInfo} from 'net'
|
|||
import os from 'os'
|
||||
import net from 'net'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import * as crypto from '@atproto/crypto'
|
||||
import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
|
||||
import * as plc from '@did-plc/lib'
|
||||
|
@ -104,9 +105,13 @@ export async function createServer(
|
|||
await pds.start()
|
||||
const pdsUrl = `http://localhost:${port}`
|
||||
|
||||
const profilePic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
|
||||
)
|
||||
|
||||
return {
|
||||
pdsUrl,
|
||||
mocker: new Mocker(pdsUrl),
|
||||
mocker: new Mocker(pds, pdsUrl, profilePic),
|
||||
async close() {
|
||||
await pds.destroy()
|
||||
await plcServer.destroy()
|
||||
|
@ -118,7 +123,11 @@ class Mocker {
|
|||
agent: BskyAgent
|
||||
users: Record<string, TestUser> = {}
|
||||
|
||||
constructor(public service: string) {
|
||||
constructor(
|
||||
public pds: PDS,
|
||||
public service: string,
|
||||
public profilePic: Uint8Array,
|
||||
) {
|
||||
this.agent = new BskyAgent({service})
|
||||
}
|
||||
|
||||
|
@ -152,6 +161,15 @@ class Mocker {
|
|||
handle: name + '.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
await agent.upsertProfile(async () => {
|
||||
const blob = await agent.uploadBlob(this.profilePic, {
|
||||
encoding: 'image/jpeg',
|
||||
})
|
||||
return {
|
||||
displayName: name,
|
||||
avatar: blob.data.blob,
|
||||
}
|
||||
})
|
||||
this.users[name] = {
|
||||
did: res.data.did,
|
||||
email,
|
||||
|
@ -192,6 +210,133 @@ class Mocker {
|
|||
await this.follow('carla', 'alice')
|
||||
await this.follow('carla', 'bob')
|
||||
}
|
||||
|
||||
async createPost(user: string, text: string) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async createQuotePost(
|
||||
user: string,
|
||||
text: string,
|
||||
{uri, cid}: {uri: string; cid: string},
|
||||
) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async createReply(
|
||||
user: string,
|
||||
text: string,
|
||||
{uri, cid}: {uri: string; cid: string},
|
||||
) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.post({
|
||||
text,
|
||||
reply: {root: {uri, cid}, parent: {uri, cid}},
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async like(user: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const agent = this.users[user]?.agent
|
||||
if (!agent) {
|
||||
throw new Error(`Not a user: ${user}`)
|
||||
}
|
||||
return await agent.like(uri, cid)
|
||||
}
|
||||
|
||||
async labelAccount(label: string, user: string) {
|
||||
const did = this.users[user]?.did
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: did,
|
||||
cid: '',
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelProfile(label: string, user: string) {
|
||||
const agent = this.users[user]?.agent
|
||||
const did = this.users[user]?.did
|
||||
if (!did) {
|
||||
throw new Error(`Invalid user: ${user}`)
|
||||
}
|
||||
|
||||
const profile = await agent.app.bsky.actor.profile.get({
|
||||
repo: user + '.test',
|
||||
rkey: 'self',
|
||||
})
|
||||
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri: profile.uri,
|
||||
cid: profile.cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
|
||||
async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
|
||||
const ctx = this.pds.ctx
|
||||
if (!ctx) {
|
||||
throw new Error('Invalid PDS')
|
||||
}
|
||||
await ctx.db.db
|
||||
.insertInto('label')
|
||||
.values([
|
||||
{
|
||||
src: ctx.cfg.labelerDid,
|
||||
uri,
|
||||
cid,
|
||||
val: label,
|
||||
neg: 0,
|
||||
cts: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
const checkAvailablePort = (port: number) =>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
|
@ -22,7 +22,7 @@
|
|||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.2.9",
|
||||
"@atproto/api": "0.2.10",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@expo/webpack-config": "^18.0.1",
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||
|
||||
export interface LabelValGroup {
|
||||
id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
|
||||
title: string
|
||||
subtitle?: string
|
||||
warning?: string
|
||||
values: string[]
|
||||
}
|
||||
import {LabelValGroup} from './types'
|
||||
|
||||
export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'illegal',
|
||||
title: 'Illegal Content',
|
||||
warning: 'Illegal Content',
|
||||
values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
|
||||
imagesOnly: false, // not applicable
|
||||
}
|
||||
|
||||
export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
|
||||
id: 'unknown',
|
||||
title: 'Unknown Label',
|
||||
warning: 'Content Warning',
|
||||
values: [],
|
||||
imagesOnly: false,
|
||||
}
|
||||
|
||||
export const CONFIGURABLE_LABEL_GROUPS: Record<
|
||||
|
@ -30,6 +27,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'i.e. Pornography',
|
||||
warning: 'Sexually Explicit',
|
||||
values: ['porn'],
|
||||
imagesOnly: false, // apply to whole thing
|
||||
},
|
||||
nudity: {
|
||||
id: 'nudity',
|
||||
|
@ -37,6 +35,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'Including non-sexual and artistic',
|
||||
warning: 'Nudity',
|
||||
values: ['nudity'],
|
||||
imagesOnly: true,
|
||||
},
|
||||
suggestive: {
|
||||
id: 'suggestive',
|
||||
|
@ -44,6 +43,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'Does not include nudity',
|
||||
warning: 'Sexually Suggestive',
|
||||
values: ['sexual'],
|
||||
imagesOnly: true,
|
||||
},
|
||||
gore: {
|
||||
id: 'gore',
|
||||
|
@ -51,12 +51,14 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'Gore, self-harm, torture',
|
||||
warning: 'Violence',
|
||||
values: ['gore', 'self-harm', 'torture'],
|
||||
imagesOnly: true,
|
||||
},
|
||||
hate: {
|
||||
id: 'hate',
|
||||
title: 'Political Hate-Groups',
|
||||
warning: 'Hate',
|
||||
values: ['icon-kkk', 'icon-nazi'],
|
||||
imagesOnly: false,
|
||||
},
|
||||
spam: {
|
||||
id: 'spam',
|
||||
|
@ -64,6 +66,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'Excessive low-quality posts',
|
||||
warning: 'Spam',
|
||||
values: ['spam'],
|
||||
imagesOnly: false,
|
||||
},
|
||||
impersonation: {
|
||||
id: 'impersonation',
|
||||
|
@ -71,5 +74,6 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
|
|||
subtitle: 'Accounts falsely claiming to be people or orgs',
|
||||
warning: 'Impersonation',
|
||||
values: ['impersonation'],
|
||||
imagesOnly: false,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,9 +1,33 @@
|
|||
import {
|
||||
LabelValGroup,
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyFeedPost,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ILLEGAL_LABEL_GROUP,
|
||||
UNKNOWN_LABEL_GROUP,
|
||||
} from './const'
|
||||
import {
|
||||
Label,
|
||||
LabelValGroup,
|
||||
ModerationBehaviorCode,
|
||||
PostModeration,
|
||||
ProfileModeration,
|
||||
PostLabelInfo,
|
||||
ProfileLabelInfo,
|
||||
} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| {$type: string; [k: string]: unknown}
|
||||
|
||||
export function getLabelValueGroup(labelVal: string): LabelValGroup {
|
||||
let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
|
||||
|
@ -17,3 +41,280 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
|
|||
}
|
||||
return UNKNOWN_LABEL_GROUP
|
||||
}
|
||||
|
||||
export function getPostModeration(
|
||||
store: RootStoreModel,
|
||||
postInfo: PostLabelInfo,
|
||||
): PostModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
postInfo.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
postInfo.profileLabels,
|
||||
)
|
||||
const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
|
||||
return hidePostNoOverride(postPref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
thread: hide(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
thread: hide(profilePref.desc.warning),
|
||||
view: warn(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (postPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(postPref.desc.warning),
|
||||
thread: hide(postPref.desc.warning),
|
||||
view: warn(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// muting
|
||||
if (postInfo.isMuted) {
|
||||
return {
|
||||
avatar,
|
||||
list: hide('Post from an account you muted.'),
|
||||
thread: warn('Post from an account you muted.'),
|
||||
view: warn('Post from an account you muted.'),
|
||||
}
|
||||
}
|
||||
|
||||
// warning cases
|
||||
if (postPref.pref === 'warn') {
|
||||
if (postPref.desc.imagesOnly) {
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
|
||||
thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
|
||||
view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
|
||||
}
|
||||
}
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(postPref.desc.warning),
|
||||
thread: warnContent(postPref.desc.warning),
|
||||
view: warnContent(postPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list: warnContent(accountPref.desc.warning),
|
||||
thread: warnContent(accountPref.desc.warning),
|
||||
view: warnContent(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: show(),
|
||||
thread: show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileModeration(
|
||||
store: RootStoreModel,
|
||||
profileLabels: ProfileLabelInfo,
|
||||
): ProfileModeration {
|
||||
const accountPref = store.preferences.getLabelPreference(
|
||||
profileLabels.accountLabels,
|
||||
)
|
||||
const profilePref = store.preferences.getLabelPreference(
|
||||
profileLabels.profileLabels,
|
||||
)
|
||||
|
||||
// avatar
|
||||
let avatar = {
|
||||
warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
|
||||
blur:
|
||||
accountPref.pref === 'hide' ||
|
||||
accountPref.pref === 'warn' ||
|
||||
profilePref.pref === 'hide' ||
|
||||
profilePref.pref === 'warn',
|
||||
}
|
||||
|
||||
// hide no-override cases
|
||||
if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(accountPref.desc.warning)
|
||||
}
|
||||
if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
|
||||
return hideProfileNoOverride(profilePref.desc.warning)
|
||||
}
|
||||
|
||||
// hide cases
|
||||
if (accountPref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(accountPref.desc.warning),
|
||||
view: hide(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
if (profilePref.pref === 'hide') {
|
||||
return {
|
||||
avatar,
|
||||
list: hide(profilePref.desc.warning),
|
||||
view: hide(profilePref.desc.warning),
|
||||
}
|
||||
}
|
||||
|
||||
// warn cases
|
||||
if (accountPref.pref === 'warn') {
|
||||
return {
|
||||
avatar,
|
||||
list: warn(accountPref.desc.warning),
|
||||
view: warn(accountPref.desc.warning),
|
||||
}
|
||||
}
|
||||
// we don't warn for this
|
||||
// if (profilePref.pref === 'warn') {
|
||||
// return {
|
||||
// avatar,
|
||||
// list: warn(profilePref.desc.warning),
|
||||
// view: warn(profilePref.desc.warning),
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
avatar,
|
||||
list: show(),
|
||||
view: show(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getProfileViewBasicLabelInfo(
|
||||
profile: AppBskyActorDefs.ProfileViewBasic,
|
||||
): ProfileLabelInfo {
|
||||
return {
|
||||
accountLabels: filterAccountLabels(profile.labels),
|
||||
profileLabels: filterProfileLabels(profile.labels),
|
||||
isMuted: profile.viewer?.muted || false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmbedLabels(embed?: Embed): Label[] {
|
||||
if (!embed) {
|
||||
return []
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
|
||||
AppBskyFeedPost.isRecord(embed.record.record.value) &&
|
||||
AppBskyFeedPost.validateRecord(embed.record.record.value).success
|
||||
) {
|
||||
return embed.record.record.labels || []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function filterAccountLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(
|
||||
label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
export function filterProfileLabels(labels?: Label[]): Label[] {
|
||||
if (!labels) {
|
||||
return []
|
||||
}
|
||||
return labels.filter(label =>
|
||||
label.uri.endsWith('/app.bsky.actor.profile/self'),
|
||||
)
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
function show() {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Show,
|
||||
}
|
||||
}
|
||||
|
||||
function hidePostNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
thread: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideProfileNoOverride(reason: string) {
|
||||
return {
|
||||
avatar: {warn: true, blur: true},
|
||||
list: hideNoOverride(reason),
|
||||
view: hideNoOverride(reason),
|
||||
}
|
||||
}
|
||||
|
||||
function hideNoOverride(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
noOverride: true,
|
||||
}
|
||||
}
|
||||
|
||||
function hide(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Hide,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warn(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.Warn,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warnContent(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnContent,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
function warnImages(reason: string) {
|
||||
return {
|
||||
behavior: ModerationBehaviorCode.WarnImages,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {cleanError} from 'lib/strings/errors'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {
|
||||
getEmbedLabels,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
getPostModeration,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
export class PostThreadItemModel {
|
||||
// ui state
|
||||
|
@ -46,6 +53,21 @@ export class PostThreadItemModel {
|
|||
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
return {
|
||||
postLabels: (this.post.labels || []).concat(
|
||||
getEmbedLabels(this.post.embed),
|
||||
),
|
||||
accountLabels: filterAccountLabels(this.post.author.labels),
|
||||
profileLabels: filterProfileLabels(this.post.author.labels),
|
||||
isMuted: this.post.author.viewer?.muted || false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
|
|
|
@ -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 {FollowState} from '../cache/my-follows'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types'
|
||||
import {
|
||||
getProfileModeration,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||
|
||||
|
@ -75,6 +81,18 @@ export class ProfileModel {
|
|||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get labelInfo(): ProfileLabelInfo {
|
||||
return {
|
||||
accountLabels: filterAccountLabels(this.labels),
|
||||
profileLabels: filterProfileLabels(this.labels),
|
||||
isMuted: this.viewer?.muted || false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): ProfileModeration {
|
||||
return getProfileModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
|
|
|
@ -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 {PostThreadModel} from '../content/post-thread'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {
|
||||
PostLabelInfo,
|
||||
PostModeration,
|
||||
ModerationBehaviorCode,
|
||||
} from 'lib/labeling/types'
|
||||
import {
|
||||
getPostModeration,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
||||
const PAGE_SIZE = 30
|
||||
|
@ -90,6 +100,24 @@ export class NotificationsFeedItemModel {
|
|||
}
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
const addedInfo = this.additionalPost?.thread?.labelInfo
|
||||
return {
|
||||
postLabels: (this.labels || []).concat(addedInfo?.postLabels || []),
|
||||
accountLabels: filterAccountLabels(this.author.labels).concat(
|
||||
addedInfo?.accountLabels || [],
|
||||
),
|
||||
profileLabels: filterProfileLabels(this.author.labels).concat(
|
||||
addedInfo?.profileLabels || [],
|
||||
),
|
||||
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
get numUnreadInGroup(): number {
|
||||
if (this.additional?.length) {
|
||||
return (
|
||||
|
@ -520,16 +548,22 @@ export class NotificationsFeedModel {
|
|||
_filterNotifications(
|
||||
items: NotificationsFeedItemModel[],
|
||||
): NotificationsFeedItemModel[] {
|
||||
return items.filter(item => {
|
||||
return items
|
||||
.filter(item => {
|
||||
const hideByLabel =
|
||||
this.rootStore.preferences.getLabelPreference(item.labels).pref ===
|
||||
'hide'
|
||||
item.moderation.list.behavior === ModerationBehaviorCode.Hide
|
||||
let mutedThread = !!(
|
||||
item.reasonSubjectRootUri &&
|
||||
this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
|
||||
)
|
||||
return !hideByLabel && !mutedThread
|
||||
})
|
||||
.map(item => {
|
||||
if (item.additional?.length) {
|
||||
item.additional = this._filterNotifications(item.additional)
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
async _fetchItemModels(
|
||||
|
|
|
@ -20,6 +20,13 @@ import {
|
|||
} from 'lib/api/build-suggested-posts'
|
||||
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {
|
||||
getEmbedLabels,
|
||||
getPostModeration,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
|
@ -83,6 +90,21 @@ export class PostsFeedItemModel {
|
|||
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
return {
|
||||
postLabels: (this.post.labels || []).concat(
|
||||
getEmbedLabels(this.post.embed),
|
||||
),
|
||||
accountLabels: filterAccountLabels(this.post.author.labels),
|
||||
profileLabels: filterProfileLabels(this.post.author.labels),
|
||||
isMuted: this.post.author.viewer?.muted || false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
copy(v: FeedViewPost) {
|
||||
this.post = v.post
|
||||
this.reply = v.reply
|
||||
|
|
|
@ -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,
|
||||
} from 'react-native'
|
||||
import {AppBskyEmbedImages} from '@atproto/api'
|
||||
import {AtUri, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
|
@ -26,8 +26,14 @@ import {UserAvatar} from '../util/UserAvatar'
|
|||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||
import {Post} from '../post/Post'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {
|
||||
getProfileViewBasicLabelInfo,
|
||||
getProfileModeration,
|
||||
} from 'lib/labeling/helpers'
|
||||
import {ProfileModeration} from 'lib/labeling/types'
|
||||
|
||||
const MAX_AUTHORS = 5
|
||||
|
||||
|
@ -38,14 +44,15 @@ interface Author {
|
|||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
labels?: ComAtprotoLabelDefs.Label[]
|
||||
moderation: ProfileModeration
|
||||
}
|
||||
|
||||
export const FeedItem = observer(function FeedItem({
|
||||
export const FeedItem = observer(function ({
|
||||
item,
|
||||
}: {
|
||||
item: NotificationsFeedItemModel
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
|
@ -81,27 +88,25 @@ export const FeedItem = observer(function FeedItem({
|
|||
handle: item.author.handle,
|
||||
displayName: item.author.displayName,
|
||||
avatar: item.author.avatar,
|
||||
labels: item.author.labels,
|
||||
moderation: getProfileModeration(
|
||||
store,
|
||||
getProfileViewBasicLabelInfo(item.author),
|
||||
),
|
||||
},
|
||||
...(item.additional?.map(
|
||||
({author: {avatar, labels, handle, displayName}}) => {
|
||||
...(item.additional?.map(({author}) => {
|
||||
return {
|
||||
href: `/profile/${handle}`,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
labels,
|
||||
href: `/profile/${author.handle}`,
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
avatar: author.avatar,
|
||||
moderation: getProfileModeration(
|
||||
store,
|
||||
getProfileViewBasicLabelInfo(author),
|
||||
),
|
||||
}
|
||||
},
|
||||
) || []),
|
||||
}) || []),
|
||||
]
|
||||
}, [
|
||||
item.additional,
|
||||
item.author.avatar,
|
||||
item.author.displayName,
|
||||
item.author.handle,
|
||||
item.author.labels,
|
||||
])
|
||||
}, [store, item.additional, item.author])
|
||||
|
||||
if (item.additionalPost?.notFound) {
|
||||
// don't render anything if the target post was deleted or unfindable
|
||||
|
@ -264,7 +269,7 @@ function CondensedAuthorsList({
|
|||
<UserAvatar
|
||||
size={35}
|
||||
avatar={authors[0].avatar}
|
||||
hasWarning={!!authors[0].labels?.length}
|
||||
moderation={authors[0].moderation.avatar}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
|
@ -277,7 +282,7 @@ function CondensedAuthorsList({
|
|||
<UserAvatar
|
||||
size={35}
|
||||
avatar={author.avatar}
|
||||
hasWarning={!!author.labels?.length}
|
||||
moderation={author.moderation.avatar}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
@ -335,7 +340,7 @@ function ExpandedAuthorsList({
|
|||
<UserAvatar
|
||||
size={35}
|
||||
avatar={author.avatar}
|
||||
hasWarning={!!author.labels?.length}
|
||||
moderation={author.moderation.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
|
|
|
@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: LikeItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
did={item.actor.did}
|
||||
handle={item.actor.handle}
|
||||
displayName={item.actor.displayName}
|
||||
avatar={item.actor.avatar}
|
||||
labels={item.actor.labels}
|
||||
isFollowedBy={!!item.actor.viewer?.followedBy}
|
||||
/>
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
|
|
@ -58,15 +58,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: RepostedByItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
labels={item.labels}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
|
|
@ -145,21 +145,17 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
|
||||
if (item._isHighlightedPost) {
|
||||
return (
|
||||
<View
|
||||
<PostHider
|
||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
style={[
|
||||
styles.outer,
|
||||
styles.outerHighlighted,
|
||||
{borderTopColor: pal.colors.border},
|
||||
pal.view,
|
||||
]}>
|
||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||
moderation={item.moderation.thread}>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<Link href={authorHref} title={authorTitle} asAnchor>
|
||||
<UserAvatar
|
||||
size={52}
|
||||
avatar={item.post.author.avatar}
|
||||
hasWarning={!!item.post.author.labels?.length}
|
||||
moderation={item.moderation.avatar}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
|
@ -218,9 +214,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
</View>
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
<ContentHider
|
||||
isMuted={item.post.author.viewer?.muted === true}
|
||||
labels={item.post.labels}>
|
||||
<ContentHider moderation={item.moderation.view}>
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[
|
||||
|
@ -300,7 +294,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</PostHider>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
|
@ -309,8 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
||||
href={itemHref}
|
||||
style={[styles.outer, {borderColor: pal.colors.border}, pal.view]}
|
||||
isMuted={item.post.author.viewer?.muted === true}
|
||||
labels={item.post.labels}>
|
||||
moderation={item.moderation.thread}>
|
||||
{item._showParentReplyLine && (
|
||||
<View
|
||||
style={[
|
||||
|
@ -333,7 +326,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
<UserAvatar
|
||||
size={52}
|
||||
avatar={item.post.author.avatar}
|
||||
hasWarning={!!item.post.author.labels?.length}
|
||||
moderation={item.moderation.avatar}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
|
@ -347,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
did={item.post.author.did}
|
||||
/>
|
||||
<ContentHider
|
||||
labels={item.post.labels}
|
||||
moderation={item.moderation.thread}
|
||||
containerStyle={styles.contentHider}>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
|
|
@ -206,8 +206,7 @@ const PostLoaded = observer(
|
|||
<PostHider
|
||||
href={itemHref}
|
||||
style={[styles.outer, pal.view, pal.border, style]}
|
||||
isMuted={item.post.author.viewer?.muted === true}
|
||||
labels={item.post.labels}>
|
||||
moderation={item.moderation.list}>
|
||||
{showReplyLine && <View style={styles.replyLine} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
|
@ -215,7 +214,7 @@ const PostLoaded = observer(
|
|||
<UserAvatar
|
||||
size={52}
|
||||
avatar={item.post.author.avatar}
|
||||
hasWarning={!!item.post.author.labels?.length}
|
||||
moderation={item.moderation.avatar}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
|
@ -247,7 +246,7 @@ const PostLoaded = observer(
|
|||
</View>
|
||||
)}
|
||||
<ContentHider
|
||||
labels={item.post.labels}
|
||||
moderation={item.moderation.list}
|
||||
containerStyle={styles.contentHider}>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
|
|
@ -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,
|
||||
isThreadParent,
|
||||
showFollowBtn,
|
||||
ignoreMuteFor,
|
||||
}: {
|
||||
item: PostsFeedItemModel
|
||||
isThreadChild?: boolean
|
||||
isThreadParent?: boolean
|
||||
showReplyLine?: boolean
|
||||
showFollowBtn?: boolean
|
||||
ignoreMuteFor?: string
|
||||
ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
|
||||
}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
@ -134,8 +133,6 @@ export const FeedItem = observer(function ({
|
|||
}
|
||||
|
||||
const isSmallTop = isThreadChild
|
||||
const isMuted =
|
||||
item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
|
||||
const outerStyles = [
|
||||
styles.outer,
|
||||
pal.view,
|
||||
|
@ -149,8 +146,7 @@ export const FeedItem = observer(function ({
|
|||
testID={`feedItem-by-${item.post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={itemHref}
|
||||
isMuted={isMuted}
|
||||
labels={item.post.labels}>
|
||||
moderation={item.moderation.list}>
|
||||
{isThreadChild && (
|
||||
<View
|
||||
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
|
||||
|
@ -200,7 +196,7 @@ export const FeedItem = observer(function ({
|
|||
<UserAvatar
|
||||
size={52}
|
||||
avatar={item.post.author.avatar}
|
||||
hasWarning={!!item.post.author.labels?.length}
|
||||
moderation={item.moderation.avatar}
|
||||
/>
|
||||
</Link>
|
||||
</View>
|
||||
|
@ -236,7 +232,7 @@ export const FeedItem = observer(function ({
|
|||
</View>
|
||||
)}
|
||||
<ContentHider
|
||||
labels={item.post.labels}
|
||||
moderation={item.moderation.list}
|
||||
containerStyle={styles.contentHider}>
|
||||
{item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
@ -10,33 +10,40 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
import {useStores} from 'state/index'
|
||||
import {FollowButton} from './FollowButton'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {
|
||||
getProfileViewBasicLabelInfo,
|
||||
getProfileModeration,
|
||||
} from 'lib/labeling/helpers'
|
||||
import {ModerationBehaviorCode} from 'lib/labeling/types'
|
||||
|
||||
export function ProfileCard({
|
||||
export const ProfileCard = observer(
|
||||
({
|
||||
testID,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
description,
|
||||
labels,
|
||||
isFollowedBy,
|
||||
profile,
|
||||
noBg,
|
||||
noBorder,
|
||||
followers,
|
||||
renderButton,
|
||||
}: {
|
||||
testID?: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
isFollowedBy?: boolean
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
renderButton?: () => JSX.Element
|
||||
}) {
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const moderation = getProfileModeration(
|
||||
store,
|
||||
getProfileViewBasicLabelInfo(profile),
|
||||
)
|
||||
|
||||
if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
|
@ -46,12 +53,16 @@ export function ProfileCard({
|
|||
noBorder && styles.outerNoBorder,
|
||||
!noBg && pal.view,
|
||||
]}
|
||||
href={`/profile/${handle}`}
|
||||
title={handle}
|
||||
href={`/profile/${profile.handle}`}
|
||||
title={profile.handle}
|
||||
asAnchor>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} />
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text
|
||||
|
@ -59,12 +70,12 @@ export function ProfileCard({
|
|||
style={[s.bold, pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(displayName || handle)}
|
||||
{sanitizeDisplayName(profile.displayName || profile.handle)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
@{handle}
|
||||
@{profile.handle}
|
||||
</Text>
|
||||
{isFollowedBy && (
|
||||
{!!profile.viewer?.followedBy && (
|
||||
<View style={s.flexRow}>
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
|
@ -78,14 +89,35 @@ export function ProfileCard({
|
|||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{description ? (
|
||||
{profile.description ? (
|
||||
<View style={styles.details}>
|
||||
<Text style={pal.text} numberOfLines={4}>
|
||||
{description}
|
||||
{profile.description}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
{followers?.length ? (
|
||||
<FollowersList followers={followers} />
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const FollowersList = observer(
|
||||
({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
if (!followers?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const followersWithMods = followers
|
||||
.map(f => ({
|
||||
f,
|
||||
mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
|
||||
}))
|
||||
.filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
|
||||
|
||||
return (
|
||||
<View style={styles.followedBy}>
|
||||
<Text
|
||||
type="sm"
|
||||
|
@ -93,60 +125,44 @@ export function ProfileCard({
|
|||
numberOfLines={2}
|
||||
lineHeight={1.2}>
|
||||
Followed by{' '}
|
||||
{followers.map(f => f.displayName || f.handle).join(', ')}
|
||||
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
|
||||
</Text>
|
||||
{followers.slice(0, 3).map(f => (
|
||||
{followersWithMods.slice(0, 3).map(({f, mod}) => (
|
||||
<View key={f.did} style={styles.followedByAviContainer}>
|
||||
<View style={[styles.followedByAvi, pal.view]}>
|
||||
<UserAvatar avatar={f.avatar} size={32} />
|
||||
<UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const ProfileCardWithFollowBtn = observer(
|
||||
({
|
||||
did,
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
description,
|
||||
labels,
|
||||
isFollowedBy,
|
||||
profile,
|
||||
noBg,
|
||||
noBorder,
|
||||
followers,
|
||||
}: {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
isFollowedBy?: boolean
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const isMe = store.me.handle === handle
|
||||
const isMe = store.me.handle === profile.handle
|
||||
|
||||
return (
|
||||
<ProfileCard
|
||||
handle={handle}
|
||||
displayName={displayName}
|
||||
avatar={avatar}
|
||||
description={description}
|
||||
labels={labels}
|
||||
isFollowedBy={isFollowedBy}
|
||||
profile={profile}
|
||||
noBg={noBg}
|
||||
noBorder={noBorder}
|
||||
followers={followers}
|
||||
renderButton={isMe ? undefined : () => <FollowButton did={did} />}
|
||||
renderButton={
|
||||
isMe ? undefined : () => <FollowButton did={profile.did} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -61,15 +61,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: FollowerItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
labels={item.labels}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
|
|
@ -58,15 +58,7 @@ export const ProfileFollows = observer(function ProfileFollows({
|
|||
// loaded
|
||||
// =
|
||||
const renderItem = ({item}: {item: FollowItem}) => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
labels={item.labels}
|
||||
isFollowedBy={!!item.viewer?.followedBy}
|
||||
/>
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
|
|
|
@ -26,7 +26,7 @@ import {Text} from '../util/text/Text'
|
|||
import {RichText} from '../util/text/RichText'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels'
|
||||
import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
@ -219,7 +219,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
|
|||
])
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<UserBanner banner={view.banner} />
|
||||
<UserBanner banner={view.banner} moderation={view.moderation.avatar} />
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.buttonsLine]}>
|
||||
{isMe ? (
|
||||
|
@ -332,7 +332,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
|
|||
richText={view.descriptionRichText}
|
||||
/>
|
||||
) : undefined}
|
||||
<ProfileHeaderLabels labels={view.labels} />
|
||||
<ProfileHeaderWarnings moderation={view.moderation.view} />
|
||||
{view.viewer.muted ? (
|
||||
<View
|
||||
testID="profileHeaderMutedNotice"
|
||||
|
@ -364,7 +364,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
|
|||
<UserAvatar
|
||||
size={80}
|
||||
avatar={view.avatar}
|
||||
hasWarning={!!view.labels?.length}
|
||||
moderation={view.moderation.avatar}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
|
|
@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
|
|||
return (
|
||||
<ScrollView style={pal.view}>
|
||||
{model.profiles.map(item => (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.did}
|
||||
did={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
description={item.description}
|
||||
labels={item.labels}
|
||||
/>
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
))}
|
||||
<View style={s.footerSpacer} />
|
||||
<View style={s.footerSpacer} />
|
||||
|
|
|
@ -144,18 +144,9 @@ export const Suggestions = observer(
|
|||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.ref.did}
|
||||
did={item.ref.did}
|
||||
handle={item.ref.handle}
|
||||
displayName={item.ref.displayName}
|
||||
avatar={item.ref.avatar}
|
||||
labels={item.ref.labels}
|
||||
profile={item.ref}
|
||||
noBg
|
||||
noBorder
|
||||
description={
|
||||
item.ref.description
|
||||
? (item.ref as AppBskyActorDefs.ProfileView).description
|
||||
: ''
|
||||
}
|
||||
followers={
|
||||
item.ref.followers
|
||||
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
|
||||
|
@ -170,18 +161,9 @@ export const Suggestions = observer(
|
|||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.view.did}
|
||||
did={item.view.did}
|
||||
handle={item.view.handle}
|
||||
displayName={item.view.displayName}
|
||||
avatar={item.view.avatar}
|
||||
labels={item.view.labels}
|
||||
profile={item.view}
|
||||
noBg
|
||||
noBorder
|
||||
description={
|
||||
item.view.description
|
||||
? (item.view as AppBskyActorDefs.ProfileView).description
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -191,19 +173,9 @@ export const Suggestions = observer(
|
|||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.suggested.did}
|
||||
did={item.suggested.did}
|
||||
handle={item.suggested.handle}
|
||||
displayName={item.suggested.displayName}
|
||||
avatar={item.suggested.avatar}
|
||||
labels={item.suggested.labels}
|
||||
profile={item.suggested}
|
||||
noBg
|
||||
noBorder
|
||||
description={
|
||||
item.suggested.description
|
||||
? (item.suggested as AppBskyActorDefs.ProfileView)
|
||||
.description
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
|
|
@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
|||
<UserAvatar
|
||||
avatar={opts.authorAvatar}
|
||||
size={16}
|
||||
hasWarning={opts.authorHasWarning}
|
||||
// TODO moderation
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
@ -13,8 +13,11 @@ import {useStores} from 'state/index'
|
|||
import {colors} from 'lib/styles'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {AvatarModeration} from 'lib/labeling/types'
|
||||
|
||||
const BLUR_AMOUNT = isWeb ? 5 : 100
|
||||
|
||||
function DefaultAvatar({size}: {size: number}) {
|
||||
return (
|
||||
|
@ -40,12 +43,12 @@ function DefaultAvatar({size}: {size: number}) {
|
|||
export function UserAvatar({
|
||||
size,
|
||||
avatar,
|
||||
hasWarning,
|
||||
moderation,
|
||||
onSelectNewAvatar,
|
||||
}: {
|
||||
size: number
|
||||
avatar?: string | null
|
||||
hasWarning?: boolean
|
||||
moderation?: AvatarModeration
|
||||
onSelectNewAvatar?: (img: RNImage | null) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
|
@ -114,7 +117,7 @@ export function UserAvatar({
|
|||
)
|
||||
|
||||
const warning = useMemo(() => {
|
||||
if (!hasWarning) {
|
||||
if (!moderation?.warn) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
|
@ -126,7 +129,7 @@ export function UserAvatar({
|
|||
/>
|
||||
</View>
|
||||
)
|
||||
}, [hasWarning, size, pal])
|
||||
}, [moderation?.warn, size, pal])
|
||||
|
||||
// onSelectNewAvatar is only passed as prop on the EditProfile component
|
||||
return onSelectNewAvatar ? (
|
||||
|
@ -159,13 +162,15 @@ export function UserAvatar({
|
|||
/>
|
||||
</View>
|
||||
</DropdownButton>
|
||||
) : avatar ? (
|
||||
) : avatar &&
|
||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||
<View style={{width: size, height: size}}>
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
|
||||
contentFit="cover"
|
||||
source={{uri: avatar}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
/>
|
||||
{warning}
|
||||
</View>
|
||||
|
|
|
@ -13,13 +13,16 @@ import {
|
|||
} from 'lib/hooks/usePermissions'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {AvatarModeration} from 'lib/labeling/types'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
moderation,
|
||||
onSelectNewBanner,
|
||||
}: {
|
||||
banner?: string | null
|
||||
moderation?: AvatarModeration
|
||||
onSelectNewBanner?: (img: TImage | null) => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
|
@ -107,12 +110,14 @@ export function UserBanner({
|
|||
/>
|
||||
</View>
|
||||
</DropdownButton>
|
||||
) : banner ? (
|
||||
) : banner &&
|
||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
resizeMode="cover"
|
||||
source={{uri: banner}}
|
||||
blurRadius={moderation?.blur ? 100 : 0}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
|
|
|
@ -35,7 +35,7 @@ export function ErrorScreen({
|
|||
]}>
|
||||
<FontAwesomeIcon
|
||||
icon="exclamation"
|
||||
style={pal.textInverted}
|
||||
style={pal.textInverted as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
@ -6,32 +6,31 @@ import {
|
|||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {Text} from '../text/Text'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
|
||||
|
||||
export function ContentHider({
|
||||
testID,
|
||||
isMuted,
|
||||
labels,
|
||||
moderation,
|
||||
style,
|
||||
containerStyle,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
isMuted?: boolean
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
moderation: ModerationBehavior
|
||||
style?: StyleProp<ViewStyle>
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const pal = usePalette('default')
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const store = useStores()
|
||||
const labelPref = store.preferences.getLabelPreference(labels)
|
||||
|
||||
if (!isMuted && labelPref.pref === 'show') {
|
||||
if (
|
||||
moderation.behavior === ModerationBehaviorCode.Show ||
|
||||
moderation.behavior === ModerationBehaviorCode.Warn ||
|
||||
moderation.behavior === ModerationBehaviorCode.WarnImages
|
||||
) {
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
|
@ -39,7 +38,7 @@ export function ContentHider({
|
|||
)
|
||||
}
|
||||
|
||||
if (labelPref.pref === 'hide') {
|
||||
if (moderation.behavior === ModerationBehaviorCode.Hide) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -52,11 +51,7 @@ export function ContentHider({
|
|||
override && styles.descriptionOpen,
|
||||
]}>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
{isMuted ? (
|
||||
<>Post from an account you muted.</>
|
||||
) : (
|
||||
<>Warning: {labelPref.desc.warning || labelPref.desc.title}</>
|
||||
)}
|
||||
{moderation.reason || 'Content warning'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.showBtn}
|
||||
|
|
|
@ -6,47 +6,34 @@ import {
|
|||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Link} from '../Link'
|
||||
import {Text} from '../text/Text'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {useStores} from 'state/index'
|
||||
import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
|
||||
|
||||
export function PostHider({
|
||||
testID,
|
||||
href,
|
||||
isMuted,
|
||||
labels,
|
||||
moderation,
|
||||
style,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
href: string
|
||||
isMuted: boolean | undefined
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
href?: string
|
||||
moderation: ModerationBehavior
|
||||
style: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const bg = override ? pal.viewLight : pal.view
|
||||
|
||||
const labelPref = store.preferences.getLabelPreference(labels)
|
||||
if (labelPref.pref === 'hide') {
|
||||
return <></>
|
||||
}
|
||||
|
||||
if (!isMuted) {
|
||||
// NOTE: any further label enforcement should occur in ContentContainer
|
||||
return (
|
||||
<Link testID={testID} style={style} href={href} noFeedback>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
if (moderation.behavior === ModerationBehaviorCode.Hide) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (moderation.behavior === ModerationBehaviorCode.Warn) {
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.description, bg, pal.border]}>
|
||||
|
@ -55,7 +42,7 @@ export function PostHider({
|
|||
style={[styles.icon, pal.text]}
|
||||
/>
|
||||
<Text type="md" style={pal.textLight}>
|
||||
Post from an account you muted.
|
||||
{moderation.reason || 'Content warning'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.showBtn}
|
||||
|
@ -80,6 +67,14 @@ export function PostHider({
|
|||
)
|
||||
}
|
||||
|
||||
// NOTE: any further label enforcement should occur in ContentContainer
|
||||
return (
|
||||
<Link testID={testID} style={style} href={href} noFeedback>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
description: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
@ -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 {ViewSelector} from '../com/util/ViewSelector'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||
import {ProfileUiModel} from 'state/models/ui/profile'
|
||||
import {useStores} from 'state/index'
|
||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
||||
|
@ -140,7 +141,11 @@ export const ProfileScreen = withAuthRequired(
|
|||
)
|
||||
|
||||
return (
|
||||
<View testID="profileView" style={styles.container}>
|
||||
<ScreenHider
|
||||
testID="profileView"
|
||||
style={styles.container}
|
||||
screenDescription="profile"
|
||||
moderation={uiState.profile.moderation.view}>
|
||||
{uiState.profile.hasError ? (
|
||||
<ErrorScreen
|
||||
testID="profileErrorScreen"
|
||||
|
@ -169,7 +174,7 @@ export const ProfileScreen = withAuthRequired(
|
|||
onPress={onPressCompose}
|
||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
||||
/>
|
||||
</View>
|
||||
</ScreenHider>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -146,19 +146,14 @@ export const SearchScreen = withAuthRequired(
|
|||
scrollEventThrottle={100}>
|
||||
{query && autocompleteView.searchRes.length ? (
|
||||
<>
|
||||
{autocompleteView.searchRes.map(
|
||||
({did, handle, displayName, labels, avatar}, index) => (
|
||||
{autocompleteView.searchRes.map((profile, index) => (
|
||||
<ProfileCard
|
||||
key={did}
|
||||
testID={`searchAutoCompleteResult-${handle}`}
|
||||
handle={handle}
|
||||
displayName={displayName}
|
||||
labels={labels}
|
||||
avatar={avatar}
|
||||
key={profile.did}
|
||||
testID={`searchAutoCompleteResult-${profile.handle}`}
|
||||
profile={profile}
|
||||
noBorder={index === 0}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
) : query && !autocompleteView.searchRes.length ? (
|
||||
<View>
|
||||
|
|
|
@ -85,14 +85,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
|
|||
{autocompleteView.searchRes.length ? (
|
||||
<>
|
||||
{autocompleteView.searchRes.map((item, i) => (
|
||||
<ProfileCard
|
||||
key={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
labels={item.labels}
|
||||
noBorder={i === 0}
|
||||
/>
|
||||
<ProfileCard key={item.did} profile={item} noBorder={i === 0} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -30,10 +30,10 @@
|
|||
tlds "^1.234.0"
|
||||
typed-emitter "^2.1.0"
|
||||
|
||||
"@atproto/api@0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.9.tgz#08e29da66d1a9001d9d3ce427548c1760d805e99"
|
||||
integrity sha512-r00IqidX2YF3VUEa4MUO2Vxqp3+QhI1cSNcWgzT4LsANapzrwdDTM+rY2Ejp9na3F+unO4SWRW3o434cVmG5gw==
|
||||
"@atproto/api@0.2.10":
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.10.tgz#19c4d695f88ab4e45e4c9f2f4db5fad61590a3d2"
|
||||
integrity sha512-97UBtvIXhsgNO7bXhHk0JwDNwyqTcL1N0JT2rnXjUeLKNf2hDvomFtI50Y4RFU942uUS5W5VtM+JJuZO5Ryw5w==
|
||||
dependencies:
|
||||
"@atproto/common-web" "*"
|
||||
"@atproto/uri" "*"
|
||||
|
|
Loading…
Reference in New Issue