diff options
Diffstat (limited to 'src/view')
26 files changed, 448 insertions, 524 deletions
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} /> ))} </> ) : ( |