diff options
Diffstat (limited to 'src/view')
24 files changed, 366 insertions, 160 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6f058d39e..7336f3b95 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -201,7 +201,7 @@ export const ComposePost = observer(function ComposePost({ setError('') - if (richtext.text.trim().length === 0 && gallery.isEmpty) { + if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) { setError('Did you want to say anything?') return } diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 4c31da338..206a3205b 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -116,6 +116,16 @@ export const TextInput = React.forwardRef(function TextInputImpl( autofocus: 'end', editable: true, injectCSS: true, + onCreate({editor: editorProp}) { + // HACK + // the 'enter' animation sometimes causes autofocus to fail + // (see Composer.web.tsx in shell) + // so we wait 200ms (the anim is 150ms) and then focus manually + // -prf + setTimeout(() => { + editorProp.chain().focus('end').run() + }, 200) + }, onUpdate({editor: editorProp}) { const json = editorProp.getJSON() diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index f06716fb0..f3f07a8bd 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -158,9 +158,9 @@ export function FeedPage({ <View testID={testID} style={s.h100pct}> <Feed testID={testID ? `${testID}-feed` : undefined} + enabled={isPageFocused} feed={feed} feedParams={feedParams} - enabled={isPageFocused} pollInterval={POLL_FREQ} scrollElRef={scrollElRef} onScroll={onMainScroll} diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index d70087504..61c3609f2 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -81,12 +81,14 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( if (scrollState.current === 'settling') { if (lastDirection.current === -1 && offset < lastOffset.current) { onPageSelecting?.(position) + setSelectedPage(position) lastDirection.current = 0 } else if ( lastDirection.current === 1 && offset > lastOffset.current ) { onPageSelecting?.(position + 1) + setSelectedPage(position + 1) lastDirection.current = 0 } } else { diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 487c589e3..dcfc1eebb 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -69,13 +69,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( // capture the header bar sizing const onTabBarLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setTabBarHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setTabBarHeight(height) + } }, [setTabBarHeight], ) const onHeaderOnlyLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setHeaderOnlyHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setHeaderOnlyHeight(height) + } }, [setHeaderOnlyHeight], ) @@ -248,11 +254,14 @@ let PagerTabBar = ({ })) return ( <Animated.View + pointerEvents="box-none" style={[ isMobile ? styles.tabBarMobile : styles.tabBarDesktop, headerTransform, ]}> - <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> + <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none"> + {renderHeader?.()} + </View> <View onLayout={onTabBarLayout} style={{ diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index a4b7a4a9c..86ea4eb39 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -351,11 +351,14 @@ let PostThreadItemLoaded = ({ {post.embed && ( <ContentHider moderation={moderation.embed} + moderationDecisions={moderation.decisions} ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} + ignoreQuoteDecisions style={s.mb10}> <PostEmbeds embed={post.embed} moderation={moderation.embed} + moderationDecisions={moderation.decisions} /> </ContentHider> )} @@ -414,7 +417,7 @@ let PostThreadItemLoaded = ({ </> ) } else { - const isThreadedChild = treeView && depth > 1 + const isThreadedChild = treeView && depth > 0 return ( <PostOuterWrapper post={post} @@ -426,7 +429,11 @@ let PostThreadItemLoaded = ({ testID={`postThreadItem-by-${post.author.handle}`} href={postHref} style={[pal.view]} - moderation={moderation.content}> + moderation={moderation.content} + iconSize={isThreadedChild ? 26 : 38} + iconStyles={ + isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2} + }> <PostSandboxWarning /> <View @@ -491,10 +498,10 @@ let PostThreadItemLoaded = ({ timestamp={post.indexedAt} postHref={postHref} showAvatar={isThreadedChild} - avatarSize={26} + avatarSize={20} displayNameType="md-bold" displayNameStyle={isThreadedChild && s.ml2} - style={isThreadedChild && s.mb5} + style={isThreadedChild && s.mb2} /> <PostAlerts moderation={moderation.content} @@ -522,10 +529,14 @@ let PostThreadItemLoaded = ({ {post.embed && ( <ContentHider style={styles.contentHider} - moderation={moderation.embed}> + moderation={moderation.embed} + moderationDecisions={moderation.decisions} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} + ignoreQuoteDecisions> <PostEmbeds embed={post.embed} moderation={moderation.embed} + moderationDecisions={moderation.decisions} /> </ContentHider> )} @@ -583,7 +594,7 @@ function PostOuterWrapper({ const {isMobile} = useWebMediaQueries() const pal = usePalette('default') const styles = useStyles() - if (treeView && depth > 1) { + if (treeView && depth > 0) { return ( <View style={[ @@ -592,9 +603,9 @@ function PostOuterWrapper({ styles.cursor, { flexDirection: 'row', - paddingLeft: 20, + paddingLeft: depth === 1 ? 10 : 20, borderTopWidth: depth === 1 ? 1 : 0, - paddingTop: depth === 1 ? 8 : 0, + paddingTop: depth === 1 ? 6 : 0, }, ]}> {Array.from(Array(depth - 1)).map((_, n: number) => ( @@ -603,8 +614,8 @@ function PostOuterWrapper({ style={{ borderLeftWidth: 2, borderLeftColor: pal.colors.border, - marginLeft: n === 0 ? 14 : isMobile ? 6 : 14, - paddingLeft: n === 0 ? 18 : isMobile ? 6 : 12, + marginLeft: isMobile ? 6 : 14, + paddingLeft: isMobile ? 6 : 12, }} /> ))} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 2e8019e71..9b1bf7a49 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -196,8 +196,14 @@ function PostInner({ {post.embed ? ( <ContentHider moderation={moderation.embed} + moderationDecisions={moderation.decisions} + ignoreQuoteDecisions style={styles.contentHider}> - <PostEmbeds embed={post.embed} moderation={moderation.embed} /> + <PostEmbeds + embed={post.embed} + moderation={moderation.embed} + moderationDecisions={moderation.decisions} + /> </ContentHider> ) : null} </ContentHider> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 393c1bc91..b9ca9abdc 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -26,6 +26,7 @@ import { pollLatest, } from '#/state/queries/post-feed' import {useModerationOpts} from '#/state/queries/preferences' +import {isWeb} from '#/platform/detection' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} @@ -89,7 +90,7 @@ let Feed = ({ const isEmpty = !isFetching && !data?.pages[0]?.slices.length const checkForNew = React.useCallback(async () => { - if (!data?.pages[0] || isFetching || !onHasNew) { + if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { return } try { @@ -99,7 +100,7 @@ let Feed = ({ } catch (e) { logger.error('Poll latest failed', {feed, error: String(e)}) } - }, [feed, data, isFetching, onHasNew]) + }, [feed, data, isFetching, onHasNew, enabled]) React.useEffect(() => { // we store the interval handler in a ref to avoid needless @@ -216,19 +217,25 @@ let Feed = ({ const shouldRenderEndOfFeed = !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed - const FeedFooter = React.useCallback( - () => - isFetchingNextPage ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : shouldRenderEndOfFeed ? ( - renderEndOfFeed() - ) : ( - <View /> - ), - [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], - ) + const FeedFooter = React.useCallback(() => { + /** + * A bit of padding at the bottom of the feed as you scroll and when you + * reach the end, so that content isn't cut off by the bottom of the + * screen. + */ + const offset = Math.max(headerOffset, 32) * (isWeb ? 1 : 2) + + return isFetchingNextPage ? ( + <View style={[styles.feedFooter]}> + <ActivityIndicator /> + <View style={{height: offset}} /> + </View> + ) : shouldRenderEndOfFeed ? ( + <View style={{minHeight: offset}}>{renderEndOfFeed()}</View> + ) : ( + <View style={{height: offset}} /> + ) + }, [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed, headerOffset]) const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 63d9d5956..f63bc1a88 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -25,6 +25,7 @@ export enum KnownError { FeedgenOffline = 'FeedgenOffline', FeedgenUnknown = 'FeedgenUnknown', FeedNSFPublic = 'FeedNSFPublic', + FeedTooManyRequests = 'FeedTooManyRequests', Unknown = 'Unknown', } @@ -100,6 +101,9 @@ function FeedgenErrorMessage({ [KnownError.FeedgenUnknown]: _l( msgLingui`Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.`, ), + [KnownError.FeedTooManyRequests]: _l( + msgLingui`We're sorry, but this feed is currently receiving high traffic and is temporarily unavailable. Please try again later.`, + ), }[knownError]), [_l, knownError], ) @@ -203,6 +207,13 @@ function detectKnownError( ) { return KnownError.Block } + + // check status codes + if (error?.status === 429) { + return KnownError.FeedTooManyRequests + } + + // convert error to string and continue if (typeof error !== 'string') { error = error.toString() } diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index dfb0cfcf6..b6c509e92 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -320,9 +320,15 @@ let FeedItemInner = ({ <ContentHider testID="contentHider-embed" moderation={moderation.embed} + moderationDecisions={moderation.decisions} ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} + ignoreQuoteDecisions style={styles.embed}> - <PostEmbeds embed={post.embed} moderation={moderation.embed} /> + <PostEmbeds + embed={post.embed} + moderation={moderation.embed} + moderationDecisions={moderation.decisions} + /> </ContentHider> ) : null} </ContentHider> diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx index 48724d8b3..6630b9a83 100644 --- a/src/view/com/posts/FollowingEndOfFeed.tsx +++ b/src/view/com/posts/FollowingEndOfFeed.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet, View, Dimensions} from 'react-native' import {useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, @@ -36,7 +36,12 @@ export function FollowingEndOfFeed() { }, [navigation]) return ( - <View style={[styles.container, pal.border]}> + <View + style={[ + styles.container, + pal.border, + {minHeight: Dimensions.get('window').height * 0.75}, + ]}> <View style={styles.inner}> <Text type="xl-medium" style={[s.textCenter, pal.text]}> You've reached the end of your feed! Find some more accounts to diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 279e00d75..21972f274 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -228,6 +228,7 @@ const styles = StyleSheet.create({ outer: { borderTopWidth: 1, paddingHorizontal: 6, + paddingVertical: 4, }, outerNoBorder: { borderTopWidth: 0, @@ -237,7 +238,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, layoutAvi: { - alignSelf: 'baseline', + alignSelf: 'flex-start', width: 54, paddingLeft: 4, paddingTop: 10, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index c1de10aa5..16e98ddf2 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -412,10 +412,12 @@ let ProfileHeaderLoaded = ({ const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( - <View style={pal.view}> - <UserBanner banner={profile.banner} moderation={moderation.avatar} /> - <View style={styles.content}> - <View style={[styles.buttonsLine]}> + <View style={pal.view} pointerEvents="box-none"> + <View pointerEvents="none"> + <UserBanner banner={profile.banner} moderation={moderation.avatar} /> + </View> + <View style={styles.content} pointerEvents="box-none"> + <View style={[styles.buttonsLine]} pointerEvents="box-none"> {isMe ? ( <TouchableOpacity testID="profileHeaderEditProfileButton" @@ -468,7 +470,7 @@ let ProfileHeaderLoaded = ({ pal.text, { color: showSuggestedFollows - ? colors.white + ? pal.textInverted.color : pal.text.color, }, ]} @@ -525,7 +527,7 @@ let ProfileHeaderLoaded = ({ </NativeDropdown> ) : undefined} </View> - <View> + <View pointerEvents="none"> <Text testID="profileHeaderDisplayName" type="title-2xl" @@ -536,7 +538,7 @@ let ProfileHeaderLoaded = ({ )} </Text> </View> - <View style={styles.handleLine}> + <View style={styles.handleLine} pointerEvents="none"> {profile.viewer?.followedBy && !blockHide ? ( <View style={[styles.pill, pal.btn, s.mr5]}> <Text type="xs" style={[pal.text]}> @@ -557,7 +559,7 @@ let ProfileHeaderLoaded = ({ </View> {!blockHide && ( <> - <View style={styles.metricsLine}> + <View style={styles.metricsLine} pointerEvents="box-none"> <Link testID="profileHeaderFollowersButton" style={[s.flexRow, s.mr10]} @@ -604,12 +606,14 @@ let ProfileHeaderLoaded = ({ </Text> </View> {descriptionRT && !moderation.profile.blur ? ( - <RichText - testID="profileHeaderDescription" - style={[styles.description, pal.text]} - numberOfLines={15} - richText={descriptionRT} - /> + <View pointerEvents="none"> + <RichText + testID="profileHeaderDescription" + style={[styles.description, pal.text]} + numberOfLines={15} + richText={descriptionRT} + /> + </View> ) : undefined} </> )} diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index f648c9801..1d550aa5c 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -70,15 +70,19 @@ export function ProfileHeaderSuggestedFollows({ }) return ( - <Animated.View style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}> - <View style={{paddingVertical: OUTER_PADDING}}> + <Animated.View + pointerEvents="box-none" + style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}> + <View style={{paddingVertical: OUTER_PADDING}} pointerEvents="box-none"> <View + pointerEvents="box-none" style={{ backgroundColor: pal.viewLight.backgroundColor, height: '100%', paddingTop: INNER_PADDING / 2, }}> <View + pointerEvents="box-none" style={{ flexDirection: 'row', justifyContent: 'space-between', diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 74e36ff7b..a07b33325 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -7,7 +7,7 @@ import { DimensionValue, } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {HeartIcon} from 'lib/icons' +import {HeartIcon, HeartIconSolid} from 'lib/icons' import {s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' @@ -46,12 +46,22 @@ export function PostLoadingPlaceholder({ const pal = usePalette('default') return ( <View style={[styles.post, pal.view, style]}> - <LoadingPlaceholder width={52} height={52} style={styles.avatar} /> + <LoadingPlaceholder + width={52} + height={52} + style={[ + styles.avatar, + { + position: 'relative', + top: -6, + }, + ]} + /> <View style={[s.flex1]}> - <LoadingPlaceholder width={100} height={8} style={[s.mb10]} /> - <LoadingPlaceholder width={200} height={8} style={[s.mb5]} /> - <LoadingPlaceholder width={200} height={8} style={[s.mb5]} /> - <LoadingPlaceholder width={120} height={8} style={[s.mb10]} /> + <LoadingPlaceholder width={100} height={6} style={{marginBottom: 10}} /> + <LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} /> + <LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} /> + <LoadingPlaceholder width="80%" height={6} style={{marginBottom: 15}} /> <View style={s.flexRow}> <View style={s.flex1}> <FontAwesomeIcon @@ -90,6 +100,8 @@ export function PostFeedLoadingPlaceholder() { <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> + <PostLoadingPlaceholder /> + <PostLoadingPlaceholder /> </View> ) } @@ -102,11 +114,23 @@ export function NotificationLoadingPlaceholder({ const pal = usePalette('default') return ( <View style={[styles.notification, pal.view, style]}> - <View style={[s.flexRow, s.mb10]}> - <LoadingPlaceholder width={30} height={30} style={styles.smallAvatar} /> + <View style={{paddingLeft: 30, paddingRight: 10}}> + <HeartIconSolid + style={{color: pal.colors.backgroundLight} as ViewStyle} + size={30} + /> + </View> + <View style={{flex: 1}}> + <View style={[s.flexRow, s.mb10]}> + <LoadingPlaceholder + width={30} + height={30} + style={styles.smallAvatar} + /> + </View> + <LoadingPlaceholder width="90%" height={6} style={[s.mb5]} /> + <LoadingPlaceholder width="70%" height={6} style={[s.mb5]} /> </View> - <LoadingPlaceholder width={200} height={8} style={[s.mb5]} /> - <LoadingPlaceholder width={120} height={8} style={[s.mb5]} /> </View> ) } @@ -239,18 +263,19 @@ const styles = StyleSheet.create({ }, post: { flexDirection: 'row', - padding: 10, - margin: 1, + alignItems: 'flex-start', + paddingHorizontal: 10, + paddingTop: 20, + paddingBottom: 5, }, avatar: { borderRadius: 26, marginRight: 10, - marginLeft: 6, + marginLeft: 8, }, notification: { + flexDirection: 'row', padding: 10, - paddingLeft: 46, - margin: 1, }, profileCard: { flexDirection: 'row', diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index a13aae2b5..b1ea76621 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -1,36 +1,44 @@ import React from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {ModerationUI} from '@atproto/api' +import {ModerationUI, PostModeration} from '@atproto/api' import {Text} from '../text/Text' import {ShieldExclamation} from 'lib/icons' import {describeModerationCause} from 'lib/moderation' import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' +import {isPostMediaBlurred} from 'lib/moderation' export function ContentHider({ testID, moderation, + moderationDecisions, ignoreMute, + ignoreQuoteDecisions, style, childContainerStyle, children, }: React.PropsWithChildren<{ testID?: string moderation: ModerationUI + moderationDecisions?: PostModeration['decisions'] ignoreMute?: boolean + ignoreQuoteDecisions?: boolean style?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle> }>) { const pal = usePalette('default') const {_} = useLingui() - const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) const {openModal} = useModalControls() - if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { + if ( + !moderation.blur || + (ignoreMute && moderation.cause?.type === 'muted') || + shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions) + ) { return ( <View testID={testID} style={[styles.outer, style]}> {children} @@ -38,6 +46,7 @@ export function ContentHider({ ) } + const isMute = moderation.cause?.type === 'muted' const desc = describeModerationCause(moderation.cause, 'content') return ( <View testID={testID} style={[styles.outer, style]}> @@ -58,7 +67,6 @@ export function ContentHider({ accessibilityLabel="" style={[ styles.cover, - {paddingRight: isMobile ? 22 : 18}, moderation.noOverride ? {borderWidth: 1, borderColor: pal.colors.borderDark} : pal.viewLight, @@ -74,14 +82,22 @@ export function ContentHider({ accessibilityRole="button" accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> - <ShieldExclamation size={18} style={pal.text} /> + {isMute ? ( + <FontAwesomeIcon + icon={['far', 'eye-slash']} + size={18} + color={pal.colors.textLight} + /> + ) : ( + <ShieldExclamation size={18} style={pal.textLight} /> + )} </Pressable> - <Text type="lg" style={pal.text}> + <Text type="md" style={pal.text}> {desc.name} </Text> {!moderation.noOverride && ( <View style={styles.showBtn}> - <Text type="xl" style={pal.link}> + <Text type="lg" style={pal.link}> {override ? 'Hide' : 'Show'} </Text> </View> @@ -92,6 +108,16 @@ export function ContentHider({ ) } +function shouldIgnoreQuote( + decisions: PostModeration['decisions'] | undefined, + ignore: boolean | undefined, +): boolean { + if (!decisions || !ignore) { + return false + } + return !isPostMediaBlurred(decisions) +} + const styles = StyleSheet.create({ outer: { overflow: 'hidden', @@ -104,6 +130,7 @@ const styles = StyleSheet.create({ marginTop: 4, paddingVertical: 14, paddingLeft: 14, + paddingRight: 18, }, showBtn: { marginLeft: 'auto', diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index c2b857f54..bffb7ea1a 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -1,8 +1,8 @@ import React, {ComponentProps} from 'react' -import {StyleSheet, Pressable, View} from 'react-native' +import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native' import {ModerationUI} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Link} from '../Link' import {Text} from '../text/Text' import {addStyle} from 'lib/styles' @@ -13,9 +13,8 @@ import {msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' interface Props extends ComponentProps<typeof Link> { - // testID?: string - // href?: string - // style: StyleProp<ViewStyle> + iconSize: number + iconStyles: StyleProp<ViewStyle> moderation: ModerationUI } @@ -25,11 +24,12 @@ export function PostHider({ moderation, style, children, + iconSize, + iconStyles, ...props }: Props) { const pal = usePalette('default') const {_} = useLingui() - const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) const {openModal} = useModalControls() @@ -47,57 +47,74 @@ export function PostHider({ ) } + const isMute = moderation.cause?.type === 'muted' const desc = describeModerationCause(moderation.cause, 'content') - return ( - <> + return !override ? ( + <Pressable + onPress={() => { + if (!moderation.noOverride) { + setOverride(v => !v) + } + }} + accessibilityRole="button" + accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityLabel="" + style={[ + styles.description, + override ? {paddingBottom: 0} : undefined, + pal.view, + ]}> <Pressable onPress={() => { - if (!moderation.noOverride) { - setOverride(v => !v) - } + openModal({ + name: 'moderation-details', + context: 'content', + moderation, + }) }} accessibilityRole="button" - accessibilityHint={override ? 'Hide the content' : 'Show the content'} - accessibilityLabel="" - style={[ - styles.description, - {paddingRight: isMobile ? 22 : 18}, - pal.viewLight, - ]}> - <Pressable - onPress={() => { - openModal({ - name: 'moderation-details', - context: 'content', - moderation, - }) - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} - accessibilityHint=""> - <ShieldExclamation size={18} style={pal.text} /> - </Pressable> - <Text type="lg" style={[{flex: 1}, pal.text]} numberOfLines={1}> - {desc.name} - </Text> - {!moderation.noOverride && ( - <Text type="xl" style={[styles.showBtn, pal.link]}> - {override ? 'Hide' : 'Show'} - </Text> - )} - </Pressable> - {override && ( - <View style={[styles.childrenContainer, pal.border, pal.viewLight]}> - <Link - testID={testID} - style={addStyle(style, styles.child)} - href={href} - noFeedback> - {children} - </Link> + accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityHint=""> + <View + style={[ + pal.viewLight, + { + width: iconSize, + height: iconSize, + borderRadius: iconSize, + alignItems: 'center', + justifyContent: 'center', + }, + iconStyles, + ]}> + {isMute ? ( + <FontAwesomeIcon + icon={['far', 'eye-slash']} + size={14} + color={pal.colors.textLight} + /> + ) : ( + <ShieldExclamation size={14} style={pal.textLight} /> + )} </View> + </Pressable> + <Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}> + {desc.name} + </Text> + {!moderation.noOverride && ( + <Text type="sm" style={[styles.showBtn, pal.link]}> + {override ? 'Hide' : 'Show'} + </Text> )} - </> + </Pressable> + ) : ( + <Link + testID={testID} + style={addStyle(style, styles.child)} + href={href} + noFeedback> + {children} + </Link> ) } @@ -106,18 +123,15 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 4, - paddingVertical: 14, - paddingLeft: 18, + paddingVertical: 10, + paddingLeft: 6, + paddingRight: 18, marginTop: 1, }, showBtn: { marginLeft: 'auto', alignSelf: 'center', }, - childrenContainer: { - paddingHorizontal: 4, - paddingBottom: 6, - }, child: { borderWidth: 0, borderTopWidth: 0, diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx index d2675ca54..0f07b679b 100644 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -10,6 +10,7 @@ import { } from 'lib/moderation' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useModalControls} from '#/state/modals' export function ProfileHeaderAlerts({ @@ -31,6 +32,7 @@ export function ProfileHeaderAlerts({ return ( <View style={styles.grid}> {causes.map(cause => { + const isMute = cause.type === 'muted' const desc = describeModerationCause(cause, 'account') return ( <Pressable @@ -47,11 +49,19 @@ export function ProfileHeaderAlerts({ accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint="" style={[styles.container, pal.viewLight, style]}> - <ShieldExclamation style={pal.text} size={24} /> - <Text type="lg" style={[{flex: 1}, pal.text]}> + {isMute ? ( + <FontAwesomeIcon + icon={['far', 'eye-slash']} + size={14} + color={pal.colors.textLight} + /> + ) : ( + <ShieldExclamation style={pal.text} size={18} /> + )} + <Text type="sm" style={[{flex: 1}, pal.text]}> {desc.name} </Text> - <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> + <Text type="sm" style={[pal.link, styles.learnMoreBtn]}> <Trans>Learn More</Trans> </Text> </Pressable> diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index ca3bf1104..5c16a3b3e 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -16,6 +16,7 @@ import { AppBskyFeedDefs, AppBskyGraphDefs, ModerationUI, + PostModeration, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -28,8 +29,9 @@ import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' import {ListEmbed} from './ListEmbed' -import {isCauseALabelOnUri} from 'lib/moderation' +import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {ContentHider} from '../moderation/ContentHider' type Embed = | AppBskyEmbedRecord.View @@ -41,10 +43,12 @@ type Embed = export function PostEmbeds({ embed, moderation, + moderationDecisions, style, }: { embed?: Embed moderation: ModerationUI + moderationDecisions?: PostModeration['decisions'] style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') @@ -55,14 +59,17 @@ export function PostEmbeds({ // = if (AppBskyEmbedRecordWithMedia.isView(embed)) { const isModOnQuote = - AppBskyEmbedRecord.isViewRecord(embed.record.record) && - isCauseALabelOnUri(moderation.cause, embed.record.record.uri) + (AppBskyEmbedRecord.isViewRecord(embed.record.record) && + isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) || + (moderationDecisions && isQuoteBlurred(moderationDecisions)) const mediaModeration = isModOnQuote ? {} : moderation const quoteModeration = isModOnQuote ? moderation : {} return ( <View style={[styles.stackContainer, style]}> <PostEmbeds embed={embed.media} moderation={mediaModeration} /> - <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> + <ContentHider moderation={quoteModeration}> + <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> + </ContentHider> </View> ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 6100d42db..d07fa0434 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View, ActivityIndicator, StyleSheet} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' @@ -65,6 +65,7 @@ function HomeScreenReady({ const {hasSession} = useSession() const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const isPageFocused = useIsFocused() const [selectedPage, setSelectedPage] = React.useState<string>(initialPage) /** @@ -174,7 +175,7 @@ function HomeScreenReady({ <FeedPage key="1" testID="followingFeedPage" - isPageFocused={selectedPageIndex === 0} + isPageFocused={selectedPageIndex === 0 && isPageFocused} feed={homeFeedParams.mergeFeedEnabled ? 'home' : 'following'} feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} @@ -185,7 +186,7 @@ function HomeScreenReady({ <FeedPage key={f} testID="customFeedPage" - isPageFocused={selectedPageIndex === 1 + index} + isPageFocused={selectedPageIndex === 1 + index && isPageFocused} feed={f} renderEmptyState={renderCustomFeedEmptyState} /> @@ -201,7 +202,7 @@ function HomeScreenReady({ tabBarPosition="top"> <FeedPage testID="customFeedPage" - isPageFocused + isPageFocused={isPageFocused} feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`} renderEmptyState={renderCustomFeedEmptyState} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index d5e378ccb..ea19515b5 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -2,7 +2,7 @@ import React, {useMemo} from 'react' import {StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {CenteredView, FlatList} from '../com/util/Views' @@ -36,6 +36,8 @@ import {useQueryClient} from '@tanstack/react-query' import {useComposerControls} from '#/state/shell/composer' import {listenSoftReset} from '#/state/events' import {truncateAndInvalidate} from '#/state/queries/util' +import {Text} from '#/view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' interface SectionRef { scrollToTop: () => void @@ -420,6 +422,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( <View> <Feed testID="postsFeed" + enabled={isFocused} feed={feed} pollInterval={30e3} scrollElRef={scrollElRef} @@ -428,7 +431,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( scrollEventThrottle={1} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} - enabled={isFocused} + renderEndOfFeed={ProfileEndOfFeed} /> {(isScrolledDown || hasNew) && ( <LoadLatestBtn @@ -442,6 +445,18 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, ) +function ProfileEndOfFeed() { + const pal = usePalette('default') + + return ( + <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> + <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> + <Trans>End of feed</Trans> + </Text> + </View> + ) +} + const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 659560a25..3a0bdcc0f 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -402,7 +402,7 @@ export function ProfileFeedScreenInner({ isHeaderReady={true} renderHeader={renderHeader} onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => + {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) => isPublic ? ( <FeedSection ref={feedSectionRef} @@ -413,6 +413,7 @@ export function ProfileFeedScreenInner({ scrollElRef={ scrollElRef as React.MutableRefObject<FlatList<any> | null> } + isFocused={isFocused} /> ) : ( <CenteredView sideBorders style={[{paddingTop: headerHeight}]}> @@ -492,10 +493,11 @@ interface FeedSectionProps { headerHeight: number isScrolledDown: boolean scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown, scrollElRef}, + {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}, ref, ) { const [hasNew, setHasNew] = React.useState(false) @@ -518,6 +520,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return ( <View> <Feed + enabled={isFocused} feed={feed} pollInterval={30e3} scrollElRef={scrollElRef} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 1396b8269..3e568c8cc 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -56,6 +56,12 @@ import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {isWeb} from '#/platform/detection' import {truncateAndInvalidate} from '#/state/queries/util' +import { + usePreferencesQuery, + usePinFeedMutation, + useUnpinFeedMutation, +} from '#/state/queries/preferences' +import {logger} from '#/logger' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -129,7 +135,6 @@ function ProfileListScreenLoaded({ list, onChange() { if (isCurateList) { - // TODO(eric) should construct these strings with a fn too truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) } }, @@ -159,7 +164,13 @@ function ProfileListScreenLoaded({ isHeaderReady={true} renderHeader={renderHeader} onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + {({ + onScroll, + headerHeight, + isScrolledDown, + scrollElRef, + isFocused, + }) => ( <FeedSection ref={feedSectionRef} feed={`list|${uri}`} @@ -169,6 +180,7 @@ function ProfileListScreenLoaded({ onScroll={onScroll} headerHeight={headerHeight} isScrolledDown={isScrolledDown} + isFocused={isFocused} /> )} {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( @@ -247,19 +259,31 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const listDeleteMutation = useListDeleteMutation() const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' const isModList = list.purpose === 'app.bsky.graph.defs#modlist' - const isPinned = false // TODO const isBlocking = !!list.viewer?.blocked const isMuting = !!list.viewer?.muted const isOwner = list.creator.did === currentAccount?.did + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + const {isPending: isUnpinPending, mutateAsync: unpinFeed} = + useUnpinFeedMutation() + const isPending = isPinPending || isUnpinPending + const {data: preferences} = usePreferencesQuery() - const onTogglePinned = useCallback(async () => { + const isPinned = preferences?.feeds?.pinned?.includes(list.uri) + + const onTogglePinned = React.useCallback(async () => { Haptics.default() - // TODO - // list.togglePin().catch(e => { - // Toast.show('There was an issue contacting the server') - // logger.error('Failed to toggle pinned list', {error: e}) - // }) - }, []) + + try { + if (isPinned) { + await unpinFeed({uri: list.uri}) + } else { + await pinFeed({uri: list.uri}) + } + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to toggle pinned feed', {error: e}) + } + }, [list.uri, isPinned, pinFeed, unpinFeed]) const onSubscribeMute = useCallback(() => { openModal({ @@ -466,10 +490,11 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { avatarType="list"> {isCurateList || isPinned ? ( <Button - testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} - type={list.isPinned ? 'default' : 'inverted'} - label={list.isPinned ? 'Unpin' : 'Pin to home'} + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} onPress={onTogglePinned} + disabled={isPending} /> ) : isModList ? ( isBlocking ? ( @@ -519,10 +544,11 @@ interface FeedSectionProps { headerHeight: number isScrolledDown: boolean scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, scrollElRef, onScroll, headerHeight, isScrolledDown}, + {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused}, ref, ) { const queryClient = useQueryClient() @@ -545,6 +571,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( <View> <Feed testID="listFeed" + enabled={isFocused} feed={feed} pollInterval={30e3} scrollElRef={scrollElRef} diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index c7b5d1d2e..43dc28159 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import {View} from 'react-native' +import {PWI_ENABLED} from '#/lib/build-flags' // Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts // MIT License @@ -99,7 +100,7 @@ function NativeStackNavigator({ const {showLoggedOut} = useLoggedOutView() const {setShowLoggedOut} = useLoggedOutViewControls() const {isMobile} = useWebMediaQueries() - if (activeRouteRequiresAuth && !hasSession) { + if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) { return <LoggedOut /> } if (showLoggedOut) { |