diff options
Diffstat (limited to 'src/view')
31 files changed, 960 insertions, 378 deletions
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index e4ada5204..ae5605c5c 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -31,6 +31,7 @@ export const SuggestedFollows = ({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} noBg noBorder description={ diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx new file mode 100644 index 000000000..2e015e404 --- /dev/null +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {LabelPreference} from 'state/models/ui/preferences' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' + +export const snapPoints = [500] + +export function Component({}: {}) { + const store = useStores() + const pal = usePalette('default') + const onPressDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + return ( + <View testID="reportPostModal" style={[pal.view, styles.container]}> + <Text style={[pal.text, styles.title]}>Content Filtering</Text> + <ContentLabelPref group="nsfw" /> + <ContentLabelPref group="gore" /> + <ContentLabelPref group="hate" /> + <ContentLabelPref group="spam" /> + <ContentLabelPref group="impersonation" /> + <View style={s.flex1} /> + <TouchableOpacity testID="sendReportBtn" onPress={onPressDone}> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </LinearGradient> + </TouchableOpacity> + </View> + ) +} + +const ContentLabelPref = observer( + ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => { + const store = useStores() + const pal = usePalette('default') + return ( + <View style={[styles.contentLabelPref, pal.border]}> + <Text type="md-medium" style={[pal.text]}> + {CONFIGURABLE_LABEL_GROUPS[group].title} + </Text> + <SelectGroup + current={store.preferences.contentLabels[group]} + onChange={v => store.preferences.setContentLabelPref(group, v)} + /> + </View> + ) + }, +) + +function SelectGroup({ + current, + onChange, +}: { + current: LabelPreference + onChange: (v: LabelPreference) => void +}) { + return ( + <View style={styles.selectableBtns}> + <SelectableBtn + current={current} + value="hide" + label="Hide" + left + onChange={onChange} + /> + <SelectableBtn + current={current} + value="warn" + label="Warn" + onChange={onChange} + /> + <SelectableBtn + current={current} + value="show" + label="Show" + right + onChange={onChange} + /> + </View> + ) +} + +function SelectableBtn({ + current, + value, + label, + left, + right, + onChange, +}: { + current: string + value: LabelPreference + label: string + left?: boolean + right?: boolean + onChange: (v: LabelPreference) => void +}) { + const pal = usePalette('default') + const palPrimary = usePalette('inverted') + return ( + <TouchableOpacity + style={[ + styles.selectableBtn, + left && styles.selectableBtnLeft, + right && styles.selectableBtnRight, + pal.border, + current === value ? palPrimary.view : pal.view, + ]} + onPress={() => onChange(value)}> + <Text style={current === value ? palPrimary.text : pal.text}> + {label} + </Text> + </TouchableOpacity> + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 10, + paddingBottom: 40, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + description: { + paddingHorizontal: 2, + marginBottom: 10, + }, + + contentLabelPref: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: 10, + paddingLeft: 4, + marginBottom: 10, + borderTopWidth: 1, + }, + + selectableBtns: { + flexDirection: 'row', + }, + selectableBtn: { + flexDirection: 'row', + justifyContent: 'center', + borderWidth: 1, + borderLeftWidth: 0, + paddingHorizontal: 10, + paddingVertical: 10, + }, + selectableBtnLeft: { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, + borderLeftWidth: 1, + }, + selectableBtnRight: { + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + }, + + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index b1c7d4738..3f10ec836 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,9 +1,10 @@ import React, {useRef, useEffect} from 'react' -import {View} from 'react-native' +import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' +import {usePalette} from 'lib/hooks/usePalette' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' @@ -15,8 +16,7 @@ import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' -import {usePalette} from 'lib/hooks/usePalette' -import {StyleSheet} from 'react-native' +import * as ContentFilteringSettingsModal from './ContentFilteringSettings' const DEFAULT_SNAPPOINTS = ['90%'] @@ -77,6 +77,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = <InviteCodesModal.Component /> + } else if (activeModal?.name === 'content-filtering-settings') { + snapPoints = ContentFilteringSettingsModal.snapPoints + element = <ContentFilteringSettingsModal.Component /> } else { return <View /> } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e6d54926b..6f026e174 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -17,6 +17,7 @@ import * as CropImageModal from './crop-image/CropImage.web' import * as ChangeHandleModal from './ChangeHandle' import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' +import * as ContentFilteringSettingsModal from './ContentFilteringSettings' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -75,6 +76,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <WaitlistModal.Component /> } else if (modal.name === 'invite-codes') { element = <InviteCodesModal.Component /> + } else if (modal.name === 'content-filtering-settings') { + element = <ContentFilteringSettingsModal.Component /> } else { return null } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 2196b3469..23a3166db 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -45,7 +45,6 @@ export const Feed = observer(function Feed({ const onRefresh = React.useCallback(async () => { try { await view.refresh() - await view.markAllRead() } catch (err) { view.rootStore.log.error('Failed to refresh notifications feed', err) } diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index e77eae17e..22a354da0 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} from '@atproto/api' +import {AtUri, ComAtprotoLabelDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -38,6 +38,7 @@ interface Author { handle: string displayName?: string avatar?: string + labels?: ComAtprotoLabelDefs.Label[] } export const FeedItem = observer(function FeedItem({ @@ -129,6 +130,7 @@ export const FeedItem = observer(function FeedItem({ handle: item.author.handle, displayName: item.author.displayName, avatar: item.author.avatar, + labels: item.author.labels, }, ] if (item.additional?.length) { @@ -138,6 +140,7 @@ export const FeedItem = observer(function FeedItem({ handle: item2.author.handle, displayName: item2.author.displayName, avatar: item2.author.avatar, + labels: item.author.labels, })), ) } @@ -255,7 +258,11 @@ function CondensedAuthorsList({ href={authors[0].href} title={`@${authors[0].handle}`} asAnchor> - <UserAvatar size={35} avatar={authors[0].avatar} /> + <UserAvatar + size={35} + avatar={authors[0].avatar} + hasWarning={!!authors[0].labels?.length} + /> </Link> </View> ) @@ -264,7 +271,11 @@ function CondensedAuthorsList({ <View style={styles.avis}> {authors.slice(0, MAX_AUTHORS).map(author => ( <View key={author.href} style={s.mr5}> - <UserAvatar size={35} avatar={author.avatar} /> + <UserAvatar + size={35} + avatar={author.avatar} + hasWarning={!!author.labels?.length} + /> </View> ))} {authors.length > MAX_AUTHORS ? ( @@ -317,7 +328,11 @@ function ExpandedAuthorsList({ style={styles.expandedAuthor} asAnchor> <View style={styles.expandedAuthorAvi}> - <UserAvatar size={35} avatar={author.avatar} /> + <UserAvatar + size={35} + avatar={author.avatar} + hasWarning={!!author.labels?.length} + /> </View> <View style={s.flex1}> <Text diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 1b65c04fc..3ab9a279b 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -53,6 +53,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) { handle={item.actor.handle} displayName={item.actor.displayName} avatar={item.actor.avatar} + labels={item.actor.labels} isFollowedBy={!!item.actor.viewer?.followedBy} /> ) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 30f8fd445..9874460e9 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -64,6 +64,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} isFollowedBy={!!item.viewer?.followedBy} /> ) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 3d3647f60..6e8758f7e 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -22,7 +22,8 @@ import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' @@ -137,7 +138,11 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={styles.layout}> <View style={styles.layoutAvi}> <Link href={authorHref} title={authorTitle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> + <UserAvatar + size={52} + avatar={item.post.author.avatar} + hasWarning={!!item.post.author.labels?.length} + /> </Link> </View> <View style={styles.layoutContent}> @@ -193,17 +198,24 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> </View> <View style={[s.pl10, s.pr10, s.pb10]}> - {item.richText?.text ? ( - <View - style={[styles.postTextContainer, styles.postTextLargeContainer]}> - <RichText - type="post-text-lg" - richText={item.richText} - lineHeight={1.3} - /> - </View> - ) : undefined} - <PostEmbeds embed={item.post.embed} style={s.mb10} /> + <ContentHider + isMuted={item.post.author.viewer?.muted === true} + labels={item.post.labels}> + {item.richText?.text ? ( + <View + style={[ + styles.postTextContainer, + styles.postTextLargeContainer, + ]}> + <RichText + type="post-text-lg" + richText={item.richText} + lineHeight={1.3} + /> + </View> + ) : undefined} + <PostEmbeds embed={item.post.embed} style={s.mb10} /> + </ContentHider> {item._isHighlightedPost && hasEngagement ? ( <View style={[styles.expandedInfo, pal.border]}> {item.post.repostCount ? ( @@ -270,13 +282,13 @@ export const PostThreadItem = observer(function PostThreadItem({ ) } else { return ( - <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> - <Link + <> + <PostHider testID={`postThreadItem-by-${item.post.author.handle}`} - style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]} href={itemHref} - title={itemTitle} - noFeedback> + style={[styles.outer, {borderColor: pal.colors.border}, pal.view]} + isMuted={item.post.author.viewer?.muted === true} + labels={item.post.labels}> {item._showParentReplyLine && ( <View style={[ @@ -296,28 +308,37 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={styles.layout}> <View style={styles.layoutAvi}> <Link href={authorHref} title={authorTitle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> + <UserAvatar + size={52} + avatar={item.post.author.avatar} + hasWarning={!!item.post.author.labels?.length} + /> </Link> </View> <View style={styles.layoutContent}> <PostMeta authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} + authorHasWarning={!!item.post.author.labels?.length} timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} /> - {item.richText?.text ? ( - <View style={styles.postTextContainer}> - <RichText - type="post-text" - richText={item.richText} - style={pal.text} - lineHeight={1.3} - /> - </View> - ) : undefined} - <PostEmbeds embed={item.post.embed} style={s.mb10} /> + <ContentHider + labels={item.post.labels} + containerStyle={styles.contentHider}> + {item.richText?.text ? ( + <View style={styles.postTextContainer}> + <RichText + type="post-text" + richText={item.richText} + style={pal.text} + lineHeight={1.3} + /> + </View> + ) : undefined} + <PostEmbeds embed={item.post.embed} style={s.mb10} /> + </ContentHider> <PostCtrls itemUri={itemUri} itemCid={itemCid} @@ -345,7 +366,7 @@ export const PostThreadItem = observer(function PostThreadItem({ /> </View> </View> - </Link> + </PostHider> {item._hasMore ? ( <Link style={[ @@ -364,7 +385,7 @@ export const PostThreadItem = observer(function PostThreadItem({ /> </Link> ) : undefined} - </PostMutedWrapper> + </> ) } }) @@ -433,6 +454,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 0, paddingBottom: 10, }, + contentHider: { + marginTop: 4, + }, expandedInfo: { flexDirection: 'row', padding: 10, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index e8e741269..60d46f5cc 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -7,17 +7,22 @@ import { View, ViewStyle, } from 'react-native' +import {AppBskyFeedPost as FeedPost} from '@atproto/api' import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadModel} from 'state/models/content/post-thread' +import { + PostThreadModel, + PostThreadItemModel, +} from 'state/models/content/post-thread' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/PostCtrls' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' @@ -61,7 +66,11 @@ export const Post = observer(function Post({ // loading // = - if (!view || view.isLoading || view.params.uri !== uri) { + if ( + !view || + (!view.hasContent && view.isLoading) || + view.params.uri !== uri + ) { return ( <View style={pal.view}> <ActivityIndicator /> @@ -84,85 +93,122 @@ export const Post = observer(function Post({ // loaded // = - const item = view.thread - const record = view.thread.postRecord - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` - const itemTitle = `Post by ${item.post.author.handle}` - const authorHref = `/profile/${item.post.author.handle}` - const authorTitle = item.post.author.handle - let replyAuthorDid = '' - if (record.reply) { - const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) - replyAuthorDid = urip.hostname - } - const onPressReply = () => { - store.shell.openComposer({ - replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, - author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + return ( + <PostLoaded + item={view.thread} + record={view.thread.postRecord} + setDeleted={setDeleted} + showReplyLine={showReplyLine} + style={style} + /> + ) +}) + +const PostLoaded = observer( + ({ + item, + record, + setDeleted, + showReplyLine, + style, + }: { + item: PostThreadItemModel + record: FeedPost.Record + setDeleted: (v: boolean) => void + showReplyLine?: boolean + style?: StyleProp<ViewStyle> + }) => { + const pal = usePalette('default') + const store = useStores() + + const itemUri = item.post.uri + const itemCid = item.post.cid + const itemUrip = new AtUri(item.post.uri) + const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}` + const itemTitle = `Post by ${item.post.author.handle}` + const authorHref = `/profile/${item.post.author.handle}` + const authorTitle = item.post.author.handle + let replyAuthorDid = '' + if (record.reply) { + const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) + replyAuthorDid = urip.hostname + } + const onPressReply = React.useCallback(() => { + store.shell.openComposer({ + replyTo: { + uri: item.post.uri, + cid: item.post.cid, + text: record.text as string, + author: { + handle: item.post.author.handle, + displayName: item.post.author.displayName, + avatar: item.post.author.avatar, + }, }, - }, - }) - } - const onPressToggleRepost = () => { - return item - .toggleRepost() - .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleLike = () => { - return item - .toggleLike() - .catch(e => store.log.error('Failed to toggle like', e)) - } - const onCopyPostText = () => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - } - const onOpenTranslate = () => { - Linking.openURL( - encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), - ) - } - const onDeletePost = () => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - store.log.error('Failed to delete post', e) - Toast.show('Failed to delete post, please try again') - }, - ) - } + }) + }, [store, item, record]) - return ( - <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}> - <Link - style={[styles.outer, pal.view, pal.border, style]} + const onPressToggleRepost = React.useCallback(() => { + return item + .toggleRepost() + .catch(e => store.log.error('Failed to toggle repost', e)) + }, [item, store]) + + const onPressToggleLike = React.useCallback(() => { + return item + .toggleLike() + .catch(e => store.log.error('Failed to toggle like', e)) + }, [item, store]) + + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record.text) + Toast.show('Copied to clipboard') + }, [record]) + + const onOpenTranslate = React.useCallback(() => { + Linking.openURL( + encodeURI( + `https://translate.google.com/#auto|en|${record?.text || ''}`, + ), + ) + }, [record]) + + const onDeletePost = React.useCallback(() => { + item.delete().then( + () => { + setDeleted(true) + Toast.show('Post deleted') + }, + e => { + store.log.error('Failed to delete post', e) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [item, setDeleted, store]) + + return ( + <PostHider href={itemHref} - title={itemTitle} - noFeedback> + style={[styles.outer, pal.view, pal.border, style]} + isMuted={item.post.author.viewer?.muted === true} + labels={item.post.labels}> {showReplyLine && <View style={styles.replyLine} />} <View style={styles.layout}> <View style={styles.layoutAvi}> <Link href={authorHref} title={authorTitle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> + <UserAvatar + size={52} + avatar={item.post.author.avatar} + hasWarning={!!item.post.author.labels?.length} + /> </Link> </View> <View style={styles.layoutContent}> <PostMeta authorHandle={item.post.author.handle} authorDisplayName={item.post.author.displayName} + authorHasWarning={!!item.post.author.labels?.length} timestamp={item.post.indexedAt} postHref={itemHref} did={item.post.author.did} @@ -185,16 +231,20 @@ export const Post = observer(function Post({ /> </View> )} - {item.richText?.text ? ( - <View style={styles.postTextContainer}> - <RichText - type="post-text" - richText={item.richText} - lineHeight={1.3} - /> - </View> - ) : undefined} - <PostEmbeds embed={item.post.embed} style={s.mb10} /> + <ContentHider + labels={item.post.labels} + containerStyle={styles.contentHider}> + {item.richText?.text ? ( + <View style={styles.postTextContainer}> + <RichText + type="post-text" + richText={item.richText} + lineHeight={1.3} + /> + </View> + ) : undefined} + <PostEmbeds embed={item.post.embed} style={s.mb10} /> + </ContentHider> <PostCtrls itemUri={itemUri} itemCid={itemCid} @@ -222,10 +272,10 @@ export const Post = observer(function Post({ /> </View> </View> - </Link> - </PostMutedWrapper> - ) -}) + </PostHider> + ) + }, +) const styles = StyleSheet.create({ outer: { @@ -257,4 +307,7 @@ const styles = StyleSheet.create({ borderLeftWidth: 2, borderLeftColor: colors.gray2, }, + contentHider: { + marginTop: 4, + }, }) diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 569d11257..c2baa4d4d 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -14,7 +14,8 @@ import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' import {PostCtrls} from '../util/PostCtrls' import {PostEmbeds} from '../util/post-embeds' -import {PostMutedWrapper} from '../util/PostMuted' +import {PostHider} from '../util/moderation/PostHider' +import {ContentHider} from '../util/moderation/ContentHider' import {RichText} from '../util/text/RichText' import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' @@ -59,7 +60,7 @@ export const FeedItem = observer(function ({ return urip.hostname }, [record?.reply]) - const onPressReply = () => { + const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') store.shell.openComposer({ replyTo: { @@ -73,29 +74,34 @@ export const FeedItem = observer(function ({ }, }, }) - } - const onPressToggleRepost = () => { + }, [item, track, record, store]) + + const onPressToggleRepost = React.useCallback(() => { track('FeedItem:PostRepost') return item .toggleRepost() .catch(e => store.log.error('Failed to toggle repost', e)) - } - const onPressToggleLike = () => { + }, [track, item, store]) + + const onPressToggleLike = React.useCallback(() => { track('FeedItem:PostLike') return item .toggleLike() .catch(e => store.log.error('Failed to toggle like', e)) - } - const onCopyPostText = () => { + }, [track, item, store]) + + const onCopyPostText = React.useCallback(() => { Clipboard.setString(record?.text || '') Toast.show('Copied to clipboard') - } + }, [record]) + const onOpenTranslate = React.useCallback(() => { Linking.openURL( encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`), ) }, [record]) - const onDeletePost = () => { + + const onDeletePost = React.useCallback(() => { track('FeedItem:PostDelete') item.delete().then( () => { @@ -107,7 +113,7 @@ export const FeedItem = observer(function ({ Toast.show('Failed to delete post, please try again') }, ) - } + }, [track, item, setDeleted, store]) if (!record || deleted) { return <View /> @@ -127,97 +133,103 @@ export const FeedItem = observer(function ({ ] return ( - <PostMutedWrapper isMuted={isMuted}> - <Link - testID={`feedItem-by-${item.post.author.handle}`} - style={outerStyles} - href={itemHref} - title={itemTitle} - noFeedback> - {isThreadChild && ( - <View - style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} - /> - )} - {isThreadParent && ( - <View + <PostHider + testID={`feedItem-by-${item.post.author.handle}`} + style={outerStyles} + href={itemHref} + isMuted={isMuted} + labels={item.post.labels}> + {isThreadChild && ( + <View + style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]} + /> + )} + {isThreadParent && ( + <View + style={[ + styles.bottomReplyLine, + {borderColor: pal.colors.replyLine}, + isNoTop ? styles.bottomReplyLineNoTop : undefined, + ]} + /> + )} + {item.reasonRepost && ( + <Link + style={styles.includeReason} + href={`/profile/${item.reasonRepost.by.handle}`} + title={sanitizeDisplayName( + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + )}> + <FontAwesomeIcon + icon="retweet" style={[ - styles.bottomReplyLine, - {borderColor: pal.colors.replyLine}, - isNoTop ? styles.bottomReplyLineNoTop : undefined, + styles.includeReasonIcon, + {color: pal.colors.textLight} as FontAwesomeIconStyle, ]} /> - )} - {item.reasonRepost && ( - <Link - style={styles.includeReason} - href={`/profile/${item.reasonRepost.by.handle}`} - title={sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, - )}> - <FontAwesomeIcon - icon="retweet" - style={[ - styles.includeReasonIcon, - {color: pal.colors.textLight} as FontAwesomeIconStyle, - ]} - /> - <Text + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + Reposted by{' '} + <DesktopWebTextLink type="sm-bold" style={pal.textLight} lineHeight={1.2} - numberOfLines={1}> - Reposted by{' '} - <DesktopWebTextLink - type="sm-bold" - style={pal.textLight} - lineHeight={1.2} - numberOfLines={1} - text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - item.reasonRepost.by.handle, - )} - href={`/profile/${item.reasonRepost.by.handle}`} - /> - </Text> - </Link> - )} - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <Link href={authorHref} title={item.post.author.handle} asAnchor> - <UserAvatar size={52} avatar={item.post.author.avatar} /> - </Link> - </View> - <View style={styles.layoutContent}> - <PostMeta - authorHandle={item.post.author.handle} - authorDisplayName={item.post.author.displayName} - timestamp={item.post.indexedAt} - postHref={itemHref} - did={item.post.author.did} - showFollowBtn={showFollowBtn} + numberOfLines={1} + text={sanitizeDisplayName( + item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + )} + href={`/profile/${item.reasonRepost.by.handle}`} /> - {!isThreadChild && replyAuthorDid !== '' && ( - <View style={[s.flexRow, s.mb2, s.alignCenter]}> - <FontAwesomeIcon - icon="reply" - size={9} - style={[ - {color: pal.colors.textLight} as FontAwesomeIconStyle, - s.mr5, - ]} - /> - <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}> - Reply to - </Text> - <UserInfoText - type="md" - did={replyAuthorDid} - attr="displayName" - style={[pal.textLight, s.ml2]} - /> - </View> - )} + </Text> + </Link> + )} + <View style={styles.layout}> + <View style={styles.layoutAvi}> + <Link href={authorHref} title={item.post.author.handle} asAnchor> + <UserAvatar + size={52} + avatar={item.post.author.avatar} + hasWarning={!!item.post.author.labels?.length} + /> + </Link> + </View> + <View style={styles.layoutContent}> + <PostMeta + authorHandle={item.post.author.handle} + authorDisplayName={item.post.author.displayName} + authorHasWarning={!!item.post.author.labels?.length} + timestamp={item.post.indexedAt} + postHref={itemHref} + did={item.post.author.did} + showFollowBtn={showFollowBtn} + /> + {!isThreadChild && replyAuthorDid !== '' && ( + <View style={[s.flexRow, s.mb2, s.alignCenter]}> + <FontAwesomeIcon + icon="reply" + size={9} + style={[ + {color: pal.colors.textLight} as FontAwesomeIconStyle, + s.mr5, + ]} + /> + <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}> + Reply to + </Text> + <UserInfoText + type="md" + did={replyAuthorDid} + attr="displayName" + style={[pal.textLight, s.ml2]} + /> + </View> + )} + <ContentHider + labels={item.post.labels} + containerStyle={styles.contentHider}> {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText @@ -228,36 +240,36 @@ export const FeedItem = observer(function ({ </View> ) : undefined} <PostEmbeds embed={item.post.embed} style={styles.embed} /> - <PostCtrls - style={styles.ctrls} - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={{ - avatar: item.post.author.avatar!, - handle: item.post.author.handle, - displayName: item.post.author.displayName!, - }} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onDeletePost={onDeletePost} - /> - </View> + </ContentHider> + <PostCtrls + style={styles.ctrls} + itemUri={itemUri} + itemCid={itemCid} + itemHref={itemHref} + itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} + isAuthor={item.post.author.did === store.me.did} + replyCount={item.post.replyCount} + repostCount={item.post.repostCount} + likeCount={item.post.likeCount} + isReposted={!!item.post.viewer?.repost} + isLiked={!!item.post.viewer?.like} + onPressReply={onPressReply} + onPressToggleRepost={onPressToggleRepost} + onPressToggleLike={onPressToggleLike} + onCopyPostText={onCopyPostText} + onOpenTranslate={onOpenTranslate} + onDeletePost={onDeletePost} + /> </View> - </Link> - </PostMutedWrapper> + </View> + </PostHider> ) }) @@ -320,6 +332,9 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', paddingBottom: 4, }, + contentHider: { + marginTop: 4, + }, embed: { marginBottom: 6, }, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index dfbc2ddbd..d14d5e16d 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} from '@atproto/api' +import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -17,6 +17,7 @@ export function ProfileCard({ displayName, avatar, description, + labels, isFollowedBy, noBg, noBorder, @@ -28,6 +29,7 @@ export function ProfileCard({ displayName?: string avatar?: string description?: string + labels: ComAtprotoLabelDefs.Label[] | undefined isFollowedBy?: boolean noBg?: boolean noBorder?: boolean @@ -50,7 +52,7 @@ export function ProfileCard({ asAnchor> <View style={styles.layout}> <View style={styles.layoutAvi}> - <UserAvatar size={40} avatar={avatar} /> + <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} /> </View> <View style={styles.layoutContent}> <Text @@ -114,6 +116,7 @@ export const ProfileCardWithFollowBtn = observer( displayName, avatar, description, + labels, isFollowedBy, noBg, noBorder, @@ -124,6 +127,7 @@ export const ProfileCardWithFollowBtn = observer( displayName?: string avatar?: string description?: string + labels: ComAtprotoLabelDefs.Label[] | undefined isFollowedBy?: boolean noBg?: boolean noBorder?: boolean @@ -138,6 +142,7 @@ export const ProfileCardWithFollowBtn = observer( displayName={displayName} avatar={avatar} description={description} + labels={labels} isFollowedBy={isFollowedBy} noBg={noBg} noBorder={noBorder} diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 0ef652a98..db592075a 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -67,6 +67,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} isFollowedBy={!!item.viewer?.followedBy} /> ) diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 54b5a319a..10da79c5e 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -64,6 +64,7 @@ export const ProfileFollows = observer(function ProfileFollows({ handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} isFollowedBy={!!item.viewer?.followedBy} /> ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 1326d3ec3..d520a712f 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -27,6 +27,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 {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {NavigationProp} from 'lib/routes/types' @@ -320,6 +321,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ richText={view.descriptionRichText} /> ) : undefined} + <ProfileHeaderLabels labels={view.labels} /> {view.viewer.muted ? ( <View testID="profileHeaderMutedNotice" @@ -348,7 +350,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ onPress={onPressAvi}> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> - <UserAvatar size={80} avatar={view.avatar} /> + <UserAvatar + size={80} + avatar={view.avatar} + hasWarning={!!view.labels?.length} + /> </View> </TouchableWithoutFeedback> </View> diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx index b53965f44..5d6163d4b 100644 --- a/src/view/com/search/SearchResults.tsx +++ b/src/view/com/search/SearchResults.tsx @@ -101,6 +101,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => { displayName={item.displayName} avatar={item.avatar} description={item.description} + labels={item.labels} /> ))} <View style={s.footerSpacer} /> diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx index fd05ecc9c..88b6dffd9 100644 --- a/src/view/com/util/LoadLatestBtn.tsx +++ b/src/view/com/util/LoadLatestBtn.tsx @@ -10,31 +10,33 @@ import {useStores} from 'state/index' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => { - const store = useStores() - const safeAreaInsets = useSafeAreaInsets() - return ( - <TouchableOpacity - style={[ - styles.loadLatest, - !store.shell.minimalShellMode && { - bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]} - onPress={onPress} - hitSlop={HITSLOP}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={styles.loadLatestInner}> - <Text type="md-bold" style={styles.loadLatestText}> - Load new posts - </Text> - </LinearGradient> - </TouchableOpacity> - ) -}) +export const LoadLatestBtn = observer( + ({onPress, label}: {onPress: () => void; label: string}) => { + const store = useStores() + const safeAreaInsets = useSafeAreaInsets() + return ( + <TouchableOpacity + style={[ + styles.loadLatest, + !store.shell.minimalShellMode && { + bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), + }, + ]} + onPress={onPress} + hitSlop={HITSLOP}> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={styles.loadLatestInner}> + <Text type="md-bold" style={styles.loadLatestText}> + Load new {label} + </Text> + </LinearGradient> + </TouchableOpacity> + ) + }, +) const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx index ba33f92a7..c85f44f30 100644 --- a/src/view/com/util/LoadLatestBtn.web.tsx +++ b/src/view/com/util/LoadLatestBtn.web.tsx @@ -6,7 +6,13 @@ import {UpIcon} from 'lib/icons' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} -export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { +export const LoadLatestBtn = ({ + onPress, + label, +}: { + onPress: () => void + label: string +}) => { const pal = usePalette('default') return ( <TouchableOpacity @@ -15,7 +21,7 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => { hitSlop={HITSLOP}> <Text type="md-bold" style={pal.text}> <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} /> - Load new posts + Load new {label} </Text> </TouchableOpacity> ) @@ -25,7 +31,9 @@ const styles = StyleSheet.create({ loadLatest: { flexDirection: 'row', position: 'absolute', - left: 'calc(50vw - 80px)', + left: '50vw', + // @ts-ignore web only -prf + transform: 'translateX(-50%)', top: 30, shadowColor: '#000', shadowOpacity: 0.2, diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c46c16da0..d9dd11e05 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -15,6 +15,7 @@ interface PostMetaOpts { authorAvatar?: string authorHandle: string authorDisplayName: string | undefined + authorHasWarning: boolean postHref: string timestamp: string did?: string @@ -93,7 +94,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <View style={styles.meta}> {typeof opts.authorAvatar !== 'undefined' && ( <View style={[styles.metaItem, styles.avatar]}> - <UserAvatar avatar={opts.authorAvatar} size={16} /> + <UserAvatar + avatar={opts.authorAvatar} + size={16} + hasWarning={opts.authorHasWarning} + /> </View> )} <View style={[styles.metaItem, styles.maxWidth]}> diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx deleted file mode 100644 index 539a71ecf..000000000 --- a/src/view/com/util/PostMuted.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from './text/Text' - -export function PostMutedWrapper({ - isMuted, - children, -}: React.PropsWithChildren<{isMuted?: boolean}>) { - const pal = usePalette('default') - const [override, setOverride] = React.useState(false) - if (!isMuted || override) { - return <>{children}</> - } - return ( - <View style={[styles.container, pal.view, 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(true)}> - <Text type="md" style={pal.link}> - Show post - </Text> - </TouchableOpacity> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 18, - borderTopWidth: 1, - }, - icon: { - marginRight: 10, - }, - showBtn: { - marginLeft: 'auto', - }, -}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index ff741cd34..d18c2d697 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -44,10 +44,12 @@ function DefaultAvatar({size}: {size: number}) { export function UserAvatar({ size, avatar, + hasWarning, onSelectNewAvatar, }: { size: number avatar?: string | null + hasWarning?: boolean onSelectNewAvatar?: (img: PickedMedia | null) => void }) { const store = useStores() @@ -105,6 +107,22 @@ export function UserAvatar({ }, }, ] + + const warning = React.useMemo(() => { + if (!hasWarning) { + return <></> + } + return ( + <View style={[styles.warningIconContainer, pal.view]}> + <FontAwesomeIcon + icon="exclamation-circle" + style={styles.warningIcon} + size={Math.floor(size / 3)} + /> + </View> + ) + }, [hasWarning, size, pal]) + // onSelectNewAvatar is only passed as prop on the EditProfile component return onSelectNewAvatar ? ( <DropdownButton @@ -137,14 +155,20 @@ export function UserAvatar({ </View> </DropdownButton> ) : avatar ? ( - <HighPriorityImage - testID="userAvatarImage" - style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} - resizeMode="stretch" - source={{uri: avatar}} - /> + <View style={{width: size, height: size}}> + <HighPriorityImage + testID="userAvatarImage" + style={{width: size, height: size, borderRadius: Math.floor(size / 2)}} + resizeMode="stretch" + source={{uri: avatar}} + /> + {warning} + </View> ) : ( - <DefaultAvatar size={size} /> + <View style={{width: size, height: size}}> + <DefaultAvatar size={size} /> + {warning} + </View> ) } @@ -165,4 +189,13 @@ const styles = StyleSheet.create({ height: 80, borderRadius: 40, }, + warningIconContainer: { + position: 'absolute', + right: 0, + bottom: 0, + borderRadius: 100, + }, + warningIcon: { + color: colors.red3, + }, }) diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx new file mode 100644 index 000000000..f65635d35 --- /dev/null +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TouchableOpacity, + 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' + +export function ContentHider({ + testID, + isMuted, + labels, + style, + containerStyle, + children, +}: React.PropsWithChildren<{ + testID?: string + isMuted?: boolean + labels: ComAtprotoLabelDefs.Label[] | undefined + 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') { + return ( + <View testID={testID} style={style}> + {children} + </View> + ) + } + + if (labelPref.pref === 'hide') { + return <></> + } + + return ( + <View style={[styles.container, pal.view, pal.border, containerStyle]}> + <View + style={[ + styles.description, + pal.viewLight, + override && styles.descriptionOpen, + ]}> + <Text type="md" style={pal.textLight}> + {isMuted ? ( + <>Post from an account you muted.</> + ) : ( + <>Warning: {labelPref.desc.title}</> + )} + </Text> + <TouchableOpacity + style={styles.showBtn} + onPress={() => setOverride(v => !v)}> + <Text type="md" style={pal.link}> + {override ? 'Hide' : 'Show'} + </Text> + </TouchableOpacity> + </View> + {override && ( + <View style={[styles.childrenContainer, pal.border]}> + <View testID={testID} style={addStyle(style, styles.child)}> + {children} + </View> + </View> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 10, + borderWidth: 1, + borderRadius: 12, + }, + description: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingLeft: 14, + paddingRight: 18, + borderRadius: 12, + }, + descriptionOpen: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + }, + icon: { + marginRight: 10, + }, + showBtn: { + marginLeft: 'auto', + }, + childrenContainer: { + paddingHorizontal: 12, + paddingTop: 8, + }, + child: {}, +}) diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx new file mode 100644 index 000000000..bafc7aecf --- /dev/null +++ b/src/view/com/util/moderation/PostHider.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TouchableOpacity, + 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' + +export function PostHider({ + testID, + href, + isMuted, + labels, + style, + children, +}: React.PropsWithChildren<{ + testID?: string + href: string + isMuted: boolean | undefined + labels: ComAtprotoLabelDefs.Label[] | undefined + style: StyleProp<ViewStyle> +}>) { + const store = useStores() + const pal = usePalette('default') + const [override, setOverride] = React.useState(false) + const bg = override ? pal.viewLight : pal.view + + const labelPref = store.preferences.getLabelPreference(labels) + if (labelPref.pref === 'hide') { + return <></> + } + + if (!isMuted) { + // NOTE: any further label enforcement should occur in ContentContainer + return ( + <Link testID={testID} style={style} href={href} noFeedback> + {children} + </Link> + ) + } + + 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> + )} + </> + ) +} + +const styles = StyleSheet.create({ + description: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 18, + borderTopWidth: 1, + }, + icon: { + marginRight: 10, + }, + showBtn: { + marginLeft: 'auto', + }, + childrenContainer: { + paddingHorizontal: 6, + paddingBottom: 6, + }, + child: { + borderWidth: 1, + borderRadius: 12, + }, +}) diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx new file mode 100644 index 000000000..e099f09a7 --- /dev/null +++ b/src/view/com/util/moderation/ProfileHeaderLabels.tsx @@ -0,0 +1,55 @@ +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.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/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 5a8be5a14..94e837238 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -42,6 +42,7 @@ export function QuoteEmbed({ authorAvatar={quote.author.avatar} authorHandle={quote.author.handle} authorDisplayName={quote.author.displayName} + authorHasWarning={false} postHref={itemHref} timestamp={quote.indexedAt} /> diff --git a/src/view/index.ts b/src/view/index.ts index 47a5f8acf..e6e342697 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -34,6 +34,7 @@ import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' +import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' @@ -106,8 +107,8 @@ export function setup() { faCompass, faEllipsis, faEnvelope, + faEye, faExclamation, - faQuoteLeft, farEyeSlash, faGear, faGlobe, @@ -128,6 +129,7 @@ export function setup() { faPenNib, faPenToSquare, faPlus, + faQuoteLeft, faReply, faRetweet, faRss, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 260df0401..fac522c68 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -194,7 +194,7 @@ const FeedPage = observer( headerOffset={HEADER_OFFSET} /> {feed.hasNewLatest && !feed.isRefreshing && ( - <LoadLatestBtn onPress={onPressLoadLatest} /> + <LoadLatestBtn onPress={onPressLoadLatest} label="posts" /> )} <FAB testID="composeFAB" diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 2a2d3c13f..8fc47b248 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,8 +1,7 @@ -import React, {useEffect} from 'react' +import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {observer} from 'mobx-react-lite' -import useAppState from 'react-native-appstate-hook' import { NativeStackScreenProps, NotificationsTabNavigatorParams, @@ -11,13 +10,12 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {InvitedUsers} from '../com/notifications/InvitedUsers' +import {LoadLatestBtn} from 'view/com/util/LoadLatestBtn' import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' -const NOTIFICATIONS_POLL_INTERVAL = 15e3 - type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' @@ -28,46 +26,21 @@ export const NotificationsScreen = withAuthRequired( const onMainScroll = useOnMainScroll(store) const scrollElRef = React.useRef<FlatList>(null) const {screen} = useAnalytics() - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) // event handlers // = - const onPressTryAgain = () => { + const onPressTryAgain = React.useCallback(() => { store.me.notifications.refresh() - } + }, [store]) + const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) }, [scrollElRef]) - // periodic polling - // = - const doPoll = React.useCallback( - async (isForegrounding = false) => { - if (isForegrounding) { - // app is foregrounding, refresh optimistically - store.log.debug('NotificationsScreen: Refreshing on app foreground') - await Promise.all([ - store.me.notifications.loadUnreadCount(), - store.me.notifications.refresh(), - ]) - } else if (appState === 'active') { - // periodic poll, refresh if there are new notifs - store.log.debug('NotificationsScreen: Polling for new notifications') - const didChange = await store.me.notifications.loadUnreadCount() - if (didChange) { - store.log.debug('NotificationsScreen: Loading new notifications') - await store.me.notifications.loadLatest() - } - } - }, - [appState, store], - ) - useEffect(() => { - const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL) - return () => clearInterval(pollInterval) - }, [doPoll]) + const onPressLoadLatest = React.useCallback(() => { + store.me.notifications.processQueue() + scrollToTop() + }, [store, scrollToTop]) // on-visible setup // = @@ -75,16 +48,16 @@ export const NotificationsScreen = withAuthRequired( React.useCallback(() => { store.shell.setMinimalShellMode(false) store.log.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(scrollToTop) - store.me.notifications.loadUnreadCount() - store.me.notifications.loadLatest() + const softResetSub = store.onScreenSoftReset(onPressLoadLatest) + store.me.notifications.syncQueue() + store.me.notifications.update() screen('Notifications') return () => { softResetSub.remove() - store.me.notifications.markAllRead() + store.me.notifications.markAllUnqueuedRead() } - }, [store, screen, scrollToTop]), + }, [store, screen, onPressLoadLatest]), ) return ( @@ -97,6 +70,10 @@ export const NotificationsScreen = withAuthRequired( onScroll={onMainScroll} scrollElRef={scrollElRef} /> + {store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing && ( + <LoadLatestBtn onPress={onPressLoadLatest} label="notifications" /> + )} </View> ) }), diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index e1fb3ec0a..ed9effd0b 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -155,6 +155,7 @@ export const SearchScreen = withAuthRequired( testID={`searchAutoCompleteResult-${item.handle}`} handle={item.handle} displayName={item.displayName} + labels={item.labels} avatar={item.avatar} /> ))} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index d429db1b6..081be8dca 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -124,6 +124,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'invite-codes'}) }, [track, store]) + const onPressContentFiltering = React.useCallback(() => { + track('Settings:ContentfilteringButtonClicked') + store.shell.openModal({name: 'content-filtering-settings'}) + }, [track, store]) + const onPressSignout = React.useCallback(() => { track('Settings:SignOutButtonClicked') store.session.logout() @@ -249,6 +254,20 @@ export const SettingsScreen = withAuthRequired( Advanced </Text> <TouchableOpacity + testID="contentFilteringBtn" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={isSwitching ? undefined : onPressContentFiltering}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="eye" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Content moderation + </Text> + </TouchableOpacity> + <TouchableOpacity testID="changeHandleBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} onPress={isSwitching ? undefined : onPressChangeHandle}> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 1bc12add1..995471944 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -90,6 +90,7 @@ export const DesktopSearch = observer(function DesktopSearch() { handle={item.handle} displayName={item.displayName} avatar={item.avatar} + labels={item.labels} noBorder={i === 0} /> ))} |