diff options
Diffstat (limited to 'src')
35 files changed, 926 insertions, 753 deletions
diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts index f68353222..6670e5413 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 b2057ff18..bac98c6a2 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 000000000..d4efb499a --- /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 76cab5c61..8f9a55032 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 7ba633366..000000000 --- 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<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 - } -} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index c26dc8749..ea75d19c6 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 6c8de3023..000000000 --- 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 220e04bce..02f58819f 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 cbff707d0..62c6da3de 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 6d2f39636..000000000 --- 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<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, - }, -}) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index a5c0ecba0..8a6578a3c 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<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}}) => { - 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({ <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}> diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index dc090e7ad..80dd59072 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}) => ( - <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 diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 65579ae23..31fa0cf7f 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -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 diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index e779f018e..8fdcce8ad 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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}> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 81f3b8c45..af78a951b 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -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}> diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx deleted file mode 100644 index 1a56a5dbf..000000000 --- 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<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}, -}) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 18481d4cb..10fc775c5 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 && ( <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}> diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 07bf4e291..154344388 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 ( - <Link - testID={testID} - style={[ - styles.outer, - pal.border, - noBorder && styles.outerNoBorder, - !noBg && pal.view, - ]} - href={`/profile/${handle}`} - title={handle} - asAnchor> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} /> - </View> - <View style={styles.layoutContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName(displayName || handle)} - </Text> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - @{handle} - </Text> - {isFollowedBy && ( - <View style={s.flexRow}> - <View style={[s.mt5, pal.btn, styles.pill]}> - <Text type="xs" style={pal.text}> - Follows You - </Text> +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 ( + <Link + testID={testID} + style={[ + styles.outer, + pal.border, + noBorder && styles.outerNoBorder, + !noBg && pal.view, + ]} + href={`/profile/${profile.handle}`} + title={profile.handle} + asAnchor> + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.avatar} + /> + </View> + <View style={styles.layoutContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName(profile.displayName || profile.handle)} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + @{profile.handle} + </Text> + {!!profile.viewer?.followedBy && ( + <View style={s.flexRow}> + <View style={[s.mt5, pal.btn, styles.pill]}> + <Text type="xs" style={pal.text}> + Follows You + </Text> + </View> </View> - </View> - )} + )} + </View> + {renderButton ? ( + <View style={styles.layoutButton}>{renderButton()}</View> + ) : undefined} </View> - {renderButton ? ( - <View style={styles.layoutButton}>{renderButton()}</View> + {profile.description ? ( + <View style={styles.details}> + <Text style={pal.text} numberOfLines={4}> + {profile.description} + </Text> + </View> ) : undefined} - </View> - {description ? ( - <View style={styles.details}> - <Text style={pal.text} numberOfLines={4}> - {description} - </Text> - </View> - ) : undefined} - {followers?.length ? ( - <View style={styles.followedBy}> - <Text - type="sm" - style={[styles.followsByDesc, pal.textLight]} - numberOfLines={2} - lineHeight={1.2}> - Followed by{' '} - {followers.map(f => f.displayName || f.handle).join(', ')} - </Text> - {followers.slice(0, 3).map(f => ( - <View key={f.did} style={styles.followedByAviContainer}> - <View style={[styles.followedByAvi, pal.view]}> - <UserAvatar avatar={f.avatar} size={32} /> - </View> + <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" + style={[styles.followsByDesc, pal.textLight]} + numberOfLines={2} + lineHeight={1.2}> + Followed by{' '} + {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')} + </Text> + {followersWithMods.slice(0, 3).map(({f, mod}) => ( + <View key={f.did} style={styles.followedByAviContainer}> + <View style={[styles.followedByAvi, pal.view]}> + <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} /> </View> - ))} - </View> - ) : undefined} - </Link> - ) -} + </View> + ))} + </View> + ) + }, +) 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} /> + } /> ) }, diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index cba171925..aeb2fcba9 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}) => ( - <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 diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index dafba62fc..0632fac02 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -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 diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index c295b2716..d1104d184 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -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> diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index 3b05f75ea..ca6a0dba2 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 ( <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} /> diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx index aacab5c98..ead17f72e 100644 --- a/src/view/com/search/Suggestions.tsx +++ b/src/view/com/search/Suggestions.tsx @@ -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> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index d9dd11e05..45651e4e5 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) { <UserAvatar avatar={opts.authorAvatar} size={16} - hasWarning={opts.authorHasWarning} + // TODO moderation /> </View> )} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9c0fe9297..7f55bf773 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({ /> </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> diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index fcd66ca7a..14459bf77 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({ /> </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 diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index dee625967..c849e37db 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -35,7 +35,7 @@ export function ErrorScreen({ ]}> <FontAwesomeIcon icon="exclamation" - style={pal.textInverted} + style={pal.textInverted as FontAwesomeIconStyle} size={24} /> </View> diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 42a97cd34..74fb479ad 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<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} diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index bafc7aecf..b3c4c9593 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -6,77 +6,72 @@ 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 (moderation.behavior === ModerationBehaviorCode.Hide) { + return null } - if (!isMuted) { - // NOTE: any further label enforcement should occur in ContentContainer + if (moderation.behavior === ModerationBehaviorCode.Warn) { return ( - <Link testID={testID} style={style} href={href} noFeedback> - {children} - </Link> + <> + <View style={[styles.description, bg, pal.border]}> + <FontAwesomeIcon + icon={['far', 'eye-slash']} + style={[styles.icon, pal.text]} + /> + <Text type="md" style={pal.textLight}> + {moderation.reason || 'Content warning'} + </Text> + <TouchableOpacity + style={styles.showBtn} + onPress={() => setOverride(v => !v)}> + <Text type="md" style={pal.link}> + {override ? 'Hide' : 'Show'} post + </Text> + </TouchableOpacity> + </View> + {override && ( + <View style={[styles.childrenContainer, pal.border, bg]}> + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + noFeedback> + {children} + </Link> + </View> + )} + </> ) } + // NOTE: any further label enforcement should occur in ContentContainer return ( - <> - <View style={[styles.description, bg, pal.border]}> - <FontAwesomeIcon - icon={['far', 'eye-slash']} - style={[styles.icon, pal.text]} - /> - <Text type="md" style={pal.textLight}> - Post from an account you muted. - </Text> - <TouchableOpacity - style={styles.showBtn} - onPress={() => setOverride(v => !v)}> - <Text type="md" style={pal.link}> - {override ? 'Hide' : 'Show'} post - </Text> - </TouchableOpacity> - </View> - {override && ( - <View style={[styles.childrenContainer, pal.border, bg]}> - <Link - testID={testID} - style={addStyle(style, styles.child)} - href={href} - noFeedback> - {children} - </Link> - </View> - )} - </> + <Link testID={testID} style={style} href={href} noFeedback> + {children} + </Link> ) } diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx deleted file mode 100644 index c6fbfaf6b..000000000 --- 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 ( - <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, - }, -}) diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx new file mode 100644 index 000000000..7a1a8e295 --- /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 ( + <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, + }, +}) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx new file mode 100644 index 000000000..2e7b07e1a --- /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<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, + }, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 4e4e3040b..4be117932 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 ( - <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> ) }), ) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index de64b2d67..4522d79ee 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) => ( - <ProfileCard - key={did} - testID={`searchAutoCompleteResult-${handle}`} - handle={handle} - displayName={displayName} - labels={labels} - avatar={avatar} - noBorder={index === 0} - /> - ), - )} + {autocompleteView.searchRes.map((profile, index) => ( + <ProfileCard + key={profile.did} + testID={`searchAutoCompleteResult-${profile.handle}`} + profile={profile} + noBorder={index === 0} + /> + ))} </> ) : query && !autocompleteView.searchRes.length ? ( <View> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 995471944..5504e9415 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) => ( - <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} /> ))} </> ) : ( |