From 1d50ddb378d5c6954d4cf8a6145b4486b9497107 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 27 Apr 2023 12:38:23 -0500 Subject: [PATCH] Refactor moderation to apply to accounts, profiles, and posts correctly (#548) * Add ScreenHider component * Add blur attribute to UserAvatar and UserBanner * Remove dead suggested posts component and model * Bump @atproto/api@0.2.10 * Rework moderation tooling to give a more precise DSL * Add label mocks * Apply finer grained moderation controls * Refactor ProfileCard to just take the profile object * Apply moderation to user listings and banner * Apply moderation to notifications * Fix lint * Tune avatar & banner blur settings per platform * 1.24 --- __e2e__/mock-server.ts | 114 +++++++ app.json | 4 +- jest/test-pds.ts | 149 ++++++++- package.json | 4 +- src/lib/labeling/const.ts | 20 +- src/lib/labeling/helpers.ts | 303 +++++++++++++++++- src/lib/labeling/types.ts | 58 ++++ src/state/models/content/post-thread.ts | 22 ++ src/state/models/content/post.ts | 122 ------- src/state/models/content/profile.ts | 18 ++ src/state/models/discovery/suggested-posts.ts | 88 ----- src/state/models/feeds/notifications.ts | 54 +++- src/state/models/feeds/posts.ts | 22 ++ src/view/com/discover/SuggestedPosts.tsx | 66 ---- src/view/com/notifications/FeedItem.tsx | 55 ++-- src/view/com/post-thread/PostLikedBy.tsx | 10 +- src/view/com/post-thread/PostRepostedBy.tsx | 10 +- src/view/com/post-thread/PostThreadItem.tsx | 25 +- src/view/com/post/Post.tsx | 7 +- src/view/com/post/PostText.tsx | 62 ---- src/view/com/posts/FeedItem.tsx | 12 +- src/view/com/profile/ProfileCard.tsx | 246 +++++++------- src/view/com/profile/ProfileFollowers.tsx | 10 +- src/view/com/profile/ProfileFollows.tsx | 10 +- src/view/com/profile/ProfileHeader.tsx | 8 +- src/view/com/search/SearchResults.tsx | 10 +- src/view/com/search/Suggestions.tsx | 34 +- src/view/com/util/PostMeta.tsx | 2 +- src/view/com/util/UserAvatar.tsx | 17 +- src/view/com/util/UserBanner.tsx | 9 +- src/view/com/util/error/ErrorScreen.tsx | 2 +- src/view/com/util/moderation/ContentHider.tsx | 25 +- src/view/com/util/moderation/PostHider.tsx | 85 +++-- .../util/moderation/ProfileHeaderLabels.tsx | 55 ---- .../util/moderation/ProfileHeaderWarnings.tsx | 44 +++ src/view/com/util/moderation/ScreenHider.tsx | 129 ++++++++ src/view/screens/Profile.tsx | 9 +- src/view/screens/SearchMobile.tsx | 21 +- src/view/shell/desktop/Search.tsx | 9 +- yarn.lock | 8 +- 40 files changed, 1195 insertions(+), 763 deletions(-) create mode 100644 src/lib/labeling/types.ts delete mode 100644 src/state/models/content/post.ts delete mode 100644 src/state/models/discovery/suggested-posts.ts delete mode 100644 src/view/com/discover/SuggestedPosts.tsx delete mode 100644 src/view/com/post/PostText.tsx delete mode 100644 src/view/com/util/moderation/ProfileHeaderLabels.tsx create mode 100644 src/view/com/util/moderation/ProfileHeaderWarnings.tsx create mode 100644 src/view/com/util/moderation/ScreenHider.tsx diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 7bcad47f..858ac5e0 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -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) diff --git a/app.json b/app.json index 9b83e55d..f4b70f0a 100644 --- a/app.json +++ b/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" diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 64963898..7f8d2023 100644 --- a/jest/test-pds.ts +++ b/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 = {} - 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) => diff --git a/package.json b/package.json index 1d19d609..939c62b6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts index f6835322..6670e541 100644 --- a/src/lib/labeling/const.ts +++ b/src/lib/labeling/const.ts @@ -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, }, } diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts index b2057ff1..bac98c6a 100644 --- a/src/lib/labeling/helpers.ts +++ b/src/lib/labeling/helpers.ts @@ -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, + } +} diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts new file mode 100644 index 00000000..d4efb499 --- /dev/null +++ b/src/lib/labeling/types.ts @@ -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 +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 76cab5c6..8f9a5503 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -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, diff --git a/src/state/models/content/post.ts b/src/state/models/content/post.ts deleted file mode 100644 index 7ba63336..00000000 --- a/src/state/models/content/post.ts +++ /dev/null @@ -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 = { - [P in keyof T as string extends P - ? never - : number extends P - ? never - : P]: T[P] -} -export class PostModel implements RemoveIndex { - // 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 - } -} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index c26dc874..ea75d19c 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -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 // = diff --git a/src/state/models/discovery/suggested-posts.ts b/src/state/models/discovery/suggested-posts.ts deleted file mode 100644 index 6c8de302..00000000 --- a/src/state/models/discovery/suggested-posts.ts +++ /dev/null @@ -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) - } - } -} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 220e04bc..02f58819 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -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 => { - const hideByLabel = - this.rootStore.preferences.getLabelPreference(item.labels).pref === - 'hide' - let mutedThread = !!( - item.reasonSubjectRootUri && - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) - ) - return !hideByLabel && !mutedThread - }) + return items + .filter(item => { + const hideByLabel = + 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( diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index cbff707d..62c6da3d 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -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 diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx deleted file mode 100644 index 6d2f3963..00000000 --- a/src/view/com/discover/SuggestedPosts.tsx +++ /dev/null @@ -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( - () => new SuggestedPostsModel(store), - [store], - ) - - React.useEffect(() => { - if (!suggestedPostsView.hasLoaded) { - suggestedPostsView.setup() - } - }, [store, suggestedPostsView]) - - return ( - <> - {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( - - Recently, on Bluesky... - - )} - {suggestedPostsView.hasContent && ( - <> - - {suggestedPostsView.posts.map(item => ( - - ))} - - - )} - {suggestedPostsView.isLoading && ( - - - - )} - - ) -}) - -const styles = StyleSheet.create({ - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingTop: 16, - paddingBottom: 8, - }, - - bottomBorder: { - borderBottomWidth: 1, - }, - - loadMore: { - paddingLeft: 12, - paddingVertical: 10, - }, -}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index a5c0ecba..8a6578a3 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -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(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}}) => { - return { - href: `/profile/${handle}`, - handle, - displayName, - avatar, - labels, - } - }, - ) || []), + ...(item.additional?.map(({author}) => { + return { + 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({ @@ -277,7 +282,7 @@ function CondensedAuthorsList({ ))} @@ -335,7 +340,7 @@ function ExpandedAuthorsList({ diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index dc090e7a..80dd5907 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) { // loaded // = const renderItem = ({item}: {item: LikeItem}) => ( - + ) return ( ( - + ) return ( + style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} + moderation={item.moderation.thread}> @@ -218,9 +214,7 @@ export const PostThreadItem = observer(function PostThreadItem({ - + {item.richText?.text ? ( - + ) } 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 && ( @@ -347,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({ did={item.post.author.did} /> {item.richText?.text ? ( diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 81f3b8c4..af78a951 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -206,8 +206,7 @@ const PostLoaded = observer( + moderation={item.moderation.list}> {showReplyLine && } @@ -215,7 +214,7 @@ const PostLoaded = observer( @@ -247,7 +246,7 @@ const PostLoaded = observer( )} {item.richText?.text ? ( diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx deleted file mode 100644 index 1a56a5db..00000000 --- a/src/view/com/post/PostText.tsx +++ /dev/null @@ -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 -}) { - const store = useStores() - const [model, setModel] = useState() - - 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 ( - - - - - - ) - } - - // error - // = - if (model.hasError) { - return ( - - - - ) - } - - // loaded - // = - return ( - - {model.text} - - ) -}) - -const styles = StyleSheet.create({ - mt6: {marginTop: 6}, -}) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 18481d4c..10fc775c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -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 && ( @@ -236,7 +232,7 @@ export const FeedItem = observer(function ({ )} {item.richText?.text ? ( diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 07bf4e29..15434438 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -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,143 +10,159 @@ 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({ - testID, - handle, - displayName, - avatar, - description, - labels, - isFollowedBy, - noBg, - noBorder, - followers, - renderButton, -}: { - testID?: string - handle: string - displayName?: string - avatar?: string - description?: string - labels: ComAtprotoLabelDefs.Label[] | undefined - isFollowedBy?: boolean - noBg?: boolean - noBorder?: boolean - followers?: AppBskyActorDefs.ProfileView[] | undefined - renderButton?: () => JSX.Element -}) { - const pal = usePalette('default') - return ( - - - - - - - - {sanitizeDisplayName(displayName || handle)} - - - @{handle} - - {isFollowedBy && ( - - - - Follows You - +export const ProfileCard = observer( + ({ + testID, + profile, + noBg, + noBorder, + followers, + renderButton, + }: { + testID?: string + 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 ( + + + + + + + + {sanitizeDisplayName(profile.displayName || profile.handle)} + + + @{profile.handle} + + {!!profile.viewer?.followedBy && ( + + + + Follows You + + - - )} + )} + + {renderButton ? ( + {renderButton()} + ) : undefined} - {renderButton ? ( - {renderButton()} + {profile.description ? ( + + + {profile.description} + + ) : undefined} - - {description ? ( - - - {description} - - - ) : undefined} - {followers?.length ? ( - - - Followed by{' '} - {followers.map(f => f.displayName || f.handle).join(', ')} - - {followers.slice(0, 3).map(f => ( - - - - + + + ) + }, +) + +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 ( + + + Followed by{' '} + {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} + + {followersWithMods.slice(0, 3).map(({f, mod}) => ( + + + - ))} - - ) : undefined} - - ) -} + + ))} + + ) + }, +) 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 ( } + renderButton={ + isMe ? undefined : () => + } /> ) }, diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index cba17192..aeb2fcba 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -61,15 +61,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // loaded // = const renderItem = ({item}: {item: FollowerItem}) => ( - + ) return ( ( - + ) return ( - + {isMe ? ( @@ -332,7 +332,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ richText={view.descriptionRichText} /> ) : undefined} - + {view.viewer.muted ? ( diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index 3b05f75e..ca6a0dba 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => { return ( {model.profiles.map(item => ( - + ))} diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx index aacab5c9..ead17f72 100644 --- a/src/view/com/search/Suggestions.tsx +++ b/src/view/com/search/Suggestions.tsx @@ -144,18 +144,9 @@ export const Suggestions = observer( ) @@ -191,19 +173,9 @@ export const Suggestions = observer( ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index d9dd11e0..45651e4e 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9c0fe929..7f55bf77 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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({ /> ) - }, [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({ /> - ) : avatar ? ( + ) : avatar && + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( {warning} diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index fcd66ca7..14459bf7 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -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({ /> - ) : banner ? ( + ) : banner && + !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( ) : ( diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 42a97cd3..74fb479a 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -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 containerStyle?: StyleProp }>) { 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 ( {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, ]}> - {isMuted ? ( - <>Post from an account you muted. - ) : ( - <>Warning: {labelPref.desc.warning || labelPref.desc.title} - )} + {moderation.reason || 'Content warning'} }>) { - 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 (moderation.behavior === ModerationBehaviorCode.Hide) { + return null } - if (!isMuted) { - // NOTE: any further label enforcement should occur in ContentContainer + if (moderation.behavior === ModerationBehaviorCode.Warn) { return ( - - {children} - + <> + + + + {moderation.reason || 'Content warning'} + + setOverride(v => !v)}> + + {override ? 'Hide' : 'Show'} post + + + + {override && ( + + + {children} + + + )} + ) } + // NOTE: any further label enforcement should occur in ContentContainer return ( - <> - - - - Post from an account you muted. - - setOverride(v => !v)}> - - {override ? 'Hide' : 'Show'} post - - - - {override && ( - - - {children} - - - )} - + + {children} + ) } diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx deleted file mode 100644 index c6fbfaf6..00000000 --- a/src/view/com/util/moderation/ProfileHeaderLabels.tsx +++ /dev/null @@ -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 ( - - - - This account has been flagged for{' '} - {(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}. - - - ) - })} - - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 10, - paddingVertical: 8, - }, -}) diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx new file mode 100644 index 00000000..7a1a8e29 --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx @@ -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 ( + + + + This account has been flagged: {moderation.reason} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 8, + }, +}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx new file mode 100644 index 00000000..2e7b07e1 --- /dev/null +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -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 + containerStyle?: StyleProp +}>) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const [override, setOverride] = React.useState(false) + const navigation = useNavigation() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (moderation.behavior !== ModerationBehaviorCode.Hide || override) { + return ( + + {children} + + ) + } + + return ( + + + + + + + + Content Warning + + + This {screenDescription} has been flagged:{' '} + {moderation.reason || 'Content warning'} + + {!isDesktopWeb && } + + + {!moderation.noOverride && ( + + )} + + + ) +} + +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, + }, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 4e4e3040..4be11793 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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 ( - + {uiState.profile.hasError ? ( } /> - + ) }), ) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index de64b2d6..4522d79e 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -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) => ( + + ))} ) : query && !autocompleteView.searchRes.length ? ( diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 99547194..5504e941 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -85,14 +85,7 @@ export const DesktopSearch = observer(function DesktopSearch() { {autocompleteView.searchRes.length ? ( <> {autocompleteView.searchRes.map((item, i) => ( - + ))} ) : ( diff --git a/yarn.lock b/yarn.lock index a6f174a2..268d46fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" "*"