diff options
Diffstat (limited to 'src/view/com/posts')
-rw-r--r-- | src/view/com/posts/AviFollowButton.tsx | 22 | ||||
-rw-r--r-- | src/view/com/posts/DiscoverFallbackHeader.tsx | 1 | ||||
-rw-r--r-- | src/view/com/posts/Feed.tsx | 290 | ||||
-rw-r--r-- | src/view/com/posts/FeedErrorMessage.tsx | 10 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 2 | ||||
-rw-r--r-- | src/view/com/posts/FeedSlice.tsx | 166 | ||||
-rw-r--r-- | src/view/com/posts/ViewFullThread.tsx | 72 |
7 files changed, 247 insertions, 316 deletions
diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index 269d4eb5a..1c894bffe 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -84,25 +84,29 @@ export function AviFollowButton({ {!isFollowing && ( <Button label={_(msg`Open ${name} profile shortcut menu`)} - hitSlop={{ - top: 0, - left: 0, - right: 5, - bottom: 5, - }} style={[ a.rounded_full, a.absolute, { - height: 30, - width: 30, bottom: -7, right: -7, }, ]}> <NativeDropdown items={items}> <View - style={[a.h_full, a.w_full, a.justify_center, a.align_center]}> + style={[ + { + // An asymmetric hit slop + // to prioritize bottom right taps. + paddingTop: 2, + paddingLeft: 2, + paddingBottom: 6, + paddingRight: 6, + }, + a.align_center, + a.justify_center, + a.rounded_full, + ]}> <View style={[ a.rounded_full, diff --git a/src/view/com/posts/DiscoverFallbackHeader.tsx b/src/view/com/posts/DiscoverFallbackHeader.tsx index 0153cf5f4..e35a33aaf 100644 --- a/src/view/com/posts/DiscoverFallbackHeader.tsx +++ b/src/view/com/posts/DiscoverFallbackHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {Trans} from '@lingui/macro' diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 905c1e0e0..c623234b8 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -16,10 +16,10 @@ import {useQueryClient} from '@tanstack/react-query' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {logEvent, useGate} from '#/lib/statsig/statsig' +import {logEvent} from '#/lib/statsig/statsig' import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' +import {isIOS, isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {STALE} from '#/state/queries' @@ -32,20 +32,17 @@ import { usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' -import { - ProgressGuide, - SuggestedFeeds, - SuggestedFollows, -} from '#/components/FeedInterstitials' +import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import {List, ListRef} from '../util/List' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FeedErrorMessage} from './FeedErrorMessage' +import {FeedItem} from './FeedItem' import {FeedShutdownMsg} from './FeedShutdownMsg' -import {FeedSlice} from './FeedSlice' +import {ViewFullThread} from './ViewFullThread' -type FeedItem = +type FeedRow = | { type: 'loading' key: string @@ -72,76 +69,29 @@ type FeedItem = slice: FeedPostSlice } | { - type: 'interstitialFeeds' + type: 'sliceItem' key: string - params: { - variant: 'default' | string - } - slot: number + slice: FeedPostSlice + indexInSlice: number + showReplyTo: boolean + } + | { + type: 'sliceViewFullThread' + key: string + uri: string } | { type: 'interstitialFollows' key: string - params: { - variant: 'default' | string - } - slot: number } | { type: 'interstitialProgressGuide' key: string - params: { - variant: 'default' | string - } - slot: number } -const feedInterstitialType = 'interstitialFeeds' -const followInterstitialType = 'interstitialFollows' -const progressGuideInterstitialType = 'interstitialProgressGuide' -const interstials: Record< - 'following' | 'discover' | 'profile', - (FeedItem & { - type: - | 'interstitialFeeds' - | 'interstitialFollows' - | 'interstitialProgressGuide' - })[] -> = { - following: [], - discover: [ - { - type: progressGuideInterstitialType, - params: { - variant: 'default', - }, - key: progressGuideInterstitialType, - slot: 0, - }, - { - type: followInterstitialType, - params: { - variant: 'default', - }, - key: followInterstitialType, - slot: 20, - }, - ], - profile: [ - { - type: followInterstitialType, - params: { - variant: 'default', - }, - key: followInterstitialType, - slot: 5, - }, - ], -} - -export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null { - if (feedItem.type === 'slice') { - return feedItem.slice +export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { + if (feedRow.type === 'sliceItem') { + return feedRow.slice } else { return null } @@ -204,7 +154,6 @@ let Feed = ({ const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef<number>(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') - const gate = useGate() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -303,8 +252,21 @@ let Feed = ({ } }, [pollInterval]) - const feedItems: FeedItem[] = React.useMemo(() => { - let arr: FeedItem[] = [] + const feedItems: FeedRow[] = React.useMemo(() => { + let feedKind: 'following' | 'discover' | 'profile' | undefined + if (feedType === 'following') { + feedKind = 'following' + } else if (feedUri === DISCOVER_FEED_URI) { + feedKind = 'discover' + } else if ( + feedType === 'author' && + (feedTab === 'posts_and_author_threads' || + feedTab === 'posts_with_replies') + ) { + feedKind = 'profile' + } + + let arr: FeedRow[] = [] if (KNOWN_SHUTDOWN_FEEDS.includes(feedUri)) { arr.push({ type: 'feedShutdownMsg', @@ -323,14 +285,77 @@ let Feed = ({ key: 'empty', }) } else if (data) { + let sliceIndex = -1 for (const page of data?.pages) { - arr = arr.concat( - page.slices.map(s => ({ - type: 'slice', - slice: s, - key: s._reactKey, - })), - ) + for (const slice of page.slices) { + sliceIndex++ + + if (hasSession) { + if (feedKind === 'discover') { + if (sliceIndex === 0) { + arr.push({ + type: 'interstitialProgressGuide', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } else if (sliceIndex === 20) { + arr.push({ + type: 'interstitialFollows', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } else if (feedKind === 'profile') { + if (sliceIndex === 5) { + arr.push({ + type: 'interstitialFollows', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } + } + } + + if (slice.isIncompleteThread && slice.items.length >= 3) { + const beforeLast = slice.items.length - 2 + const last = slice.items.length - 1 + arr.push({ + type: 'sliceItem', + key: slice.items[0]._reactKey, + slice: slice, + indexInSlice: 0, + showReplyTo: false, + }) + arr.push({ + type: 'sliceViewFullThread', + key: slice._reactKey + '-viewFullThread', + uri: slice.items[0].uri, + }) + arr.push({ + type: 'sliceItem', + key: slice.items[beforeLast]._reactKey, + slice: slice, + indexInSlice: beforeLast, + showReplyTo: + slice.items[beforeLast].parentAuthor?.did !== + slice.items[beforeLast].post.author.did, + }) + arr.push({ + type: 'sliceItem', + key: slice.items[last]._reactKey, + slice: slice, + indexInSlice: last, + showReplyTo: false, + }) + } else { + for (let i = 0; i < slice.items.length; i++) { + arr.push({ + type: 'sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + }) + } + } + } } } if (isError && !isEmpty) { @@ -346,45 +371,6 @@ let Feed = ({ }) } - if (hasSession) { - let feedKind: 'following' | 'discover' | 'profile' | undefined - if (feedType === 'following') { - feedKind = 'following' - } else if (feedUri === DISCOVER_FEED_URI) { - feedKind = 'discover' - } else if ( - feedType === 'author' && - (feedTab === 'posts_and_author_threads' || - feedTab === 'posts_with_replies') - ) { - feedKind = 'profile' - } - - if (feedKind) { - for (const interstitial of interstials[feedKind]) { - const shouldShow = - (interstitial.type === feedInterstitialType && - gate('suggested_feeds_interstitial')) || - interstitial.type === followInterstitialType || - interstitial.type === progressGuideInterstitialType - - if (shouldShow) { - const variant = 'default' // replace with experiment variant - const int = { - ...interstitial, - params: {variant}, - // overwrite key with unique value - key: [interstitial.type, variant, lastFetchedAt].join(':'), - } - - if (arr.length > interstitial.slot) { - arr.splice(interstitial.slot, 0, int) - } - } - } - } - } - return arr }, [ isFetched, @@ -395,7 +381,6 @@ let Feed = ({ feedType, feedUri, feedTab, - gate, hasSession, ]) @@ -403,7 +388,7 @@ let Feed = ({ // = const onRefresh = React.useCallback(async () => { - logEvent('feed:refresh:sampled', { + logEvent('feed:refresh', { feedType: feedType, feedUrl: feed, reason: 'pull-to-refresh', @@ -421,7 +406,7 @@ let Feed = ({ const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return - logEvent('feed:endReached:sampled', { + logEvent('feed:endReached', { feedType: feedType, feedUrl: feed, itemCount: feedItems.length, @@ -454,10 +439,10 @@ let Feed = ({ // = const renderItem = React.useCallback( - ({item, index}: ListRenderItemInfo<FeedItem>) => { - if (item.type === 'empty') { + ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { + if (row.type === 'empty') { return renderEmptyState() - } else if (item.type === 'error') { + } else if (row.type === 'error') { return ( <FeedErrorMessage feedDesc={feed} @@ -466,7 +451,7 @@ let Feed = ({ savedFeedConfig={savedFeedConfig} /> ) - } else if (item.type === 'loadMoreError') { + } else if (row.type === 'loadMoreError') { return ( <LoadMoreRetryBtn label={_( @@ -475,25 +460,48 @@ let Feed = ({ onPress={onPressRetryLoadMore} /> ) - } else if (item.type === 'loading') { + } else if (row.type === 'loading') { return <PostFeedLoadingPlaceholder /> - } else if (item.type === 'feedShutdownMsg') { + } else if (row.type === 'feedShutdownMsg') { return <FeedShutdownMsg feedUri={feedUri} /> - } else if (item.type === feedInterstitialType) { - return <SuggestedFeeds /> - } else if (item.type === followInterstitialType) { + } else if (row.type === 'interstitialFollows') { return <SuggestedFollows feed={feed} /> - } else if (item.type === progressGuideInterstitialType) { + } else if (row.type === 'interstitialProgressGuide') { return <ProgressGuide /> - } else if (item.type === 'slice') { - if (item.slice.isFallbackMarker) { + } else if (row.type === 'sliceItem') { + const slice = row.slice + if (slice.isFallbackMarker) { // HACK // tell the user we fell back to discover // see home.ts (feed api) for more info // -prf return <DiscoverFallbackHeader /> } - return <FeedSlice slice={item.slice} hideTopBorder={index === 0} /> + const indexInSlice = row.indexInSlice + const item = slice.items[indexInSlice] + return ( + <FeedItem + post={item.post} + record={item.record} + reason={indexInSlice === 0 ? slice.reason : undefined} + feedContext={slice.feedContext} + moderation={item.moderation} + parentAuthor={item.parentAuthor} + showReplyTo={row.showReplyTo} + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} + isThreadLastChild={ + isThreadChildAt(slice.items, indexInSlice) && + slice.items.length === indexInSlice + 1 + } + isParentBlocked={item.isParentBlocked} + isParentNotFound={item.isParentNotFound} + hideTopBorder={rowIndex === 0 && indexInSlice === 0} + rootPost={slice.items[0].post} + /> + ) + } else if (row.type === 'sliceViewFullThread') { + return <ViewFullThread uri={row.uri} /> } else { return null } @@ -561,7 +569,7 @@ let Feed = ({ } initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} windowSize={9} - maxToRenderPerBatch={5} + maxToRenderPerBatch={isIOS ? 5 : 1} updateCellsBatchingPeriod={40} onItemSeen={feedFeedback.onItemSeen} /> @@ -574,3 +582,17 @@ export {Feed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, }) + +function isThreadParentAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index cc7b34750..a58216233 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -25,7 +25,7 @@ export enum KnownError { FeedgenBadResponse = 'FeedgenBadResponse', FeedgenOffline = 'FeedgenOffline', FeedgenUnknown = 'FeedgenUnknown', - FeedNSFPublic = 'FeedNSFPublic', + FeedSignedInOnly = 'FeedSignedInOnly', FeedTooManyRequests = 'FeedTooManyRequests', Unknown = 'Unknown', } @@ -110,7 +110,7 @@ function FeedgenErrorMessage({ [KnownError.FeedgenOffline]: _l( msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, ), - [KnownError.FeedNSFPublic]: _l( + [KnownError.FeedSignedInOnly]: _l( msgLingui`This content is not viewable without a Bluesky account.`, ), [KnownError.FeedgenUnknown]: _l( @@ -152,7 +152,7 @@ function FeedgenErrorMessage({ const cta = React.useMemo(() => { switch (knownError) { - case KnownError.FeedNSFPublic: { + case KnownError.FeedSignedInOnly: { return null } case KnownError.FeedgenDoesNotExist: @@ -249,8 +249,8 @@ function detectKnownError( if (typeof error !== 'string') { error = error.toString() } - if (error.includes(KnownError.FeedNSFPublic)) { - return KnownError.FeedNSFPublic + if (error.includes(KnownError.FeedSignedInOnly)) { + return KnownError.FeedSignedInOnly } if (!feedDesc.startsWith('feedgen')) { return KnownError.Unknown diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 049748754..c04921c68 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -618,10 +618,10 @@ const styles = StyleSheet.create({ layout: { flexDirection: 'row', marginTop: 1, - gap: 10, }, layoutAvi: { paddingLeft: 8, + paddingRight: 10, position: 'relative', zIndex: 999, }, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx deleted file mode 100644 index dc68ee7a1..000000000 --- a/src/view/com/posts/FeedSlice.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, {memo} from 'react' -import {StyleSheet, View} from 'react-native' -import Svg, {Circle, Line} from 'react-native-svg' -import {AtUri} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {makeProfileLink} from '#/lib/routes/links' -import {FeedPostSlice} from '#/state/queries/post-feed' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {FeedItem} from './FeedItem' - -let FeedSlice = ({ - slice, - hideTopBorder, -}: { - slice: FeedPostSlice - hideTopBorder?: boolean -}): React.ReactNode => { - if (slice.isIncompleteThread && slice.items.length >= 3) { - const beforeLast = slice.items.length - 2 - const last = slice.items.length - 1 - return ( - <> - <FeedItem - key={slice.items[0]._reactKey} - post={slice.items[0].post} - record={slice.items[0].record} - reason={slice.reason} - feedContext={slice.feedContext} - parentAuthor={slice.items[0].parentAuthor} - showReplyTo={false} - moderation={slice.items[0].moderation} - isThreadParent={isThreadParentAt(slice.items, 0)} - isThreadChild={isThreadChildAt(slice.items, 0)} - hideTopBorder={hideTopBorder} - isParentBlocked={slice.items[0].isParentBlocked} - isParentNotFound={slice.items[0].isParentNotFound} - rootPost={slice.items[0].post} - /> - <ViewFullThread uri={slice.items[0].uri} /> - <FeedItem - key={slice.items[beforeLast]._reactKey} - post={slice.items[beforeLast].post} - record={slice.items[beforeLast].record} - reason={undefined} - feedContext={slice.feedContext} - parentAuthor={slice.items[beforeLast].parentAuthor} - showReplyTo={ - slice.items[beforeLast].parentAuthor?.did !== - slice.items[beforeLast].post.author.did - } - moderation={slice.items[beforeLast].moderation} - isThreadParent={isThreadParentAt(slice.items, beforeLast)} - isThreadChild={isThreadChildAt(slice.items, beforeLast)} - isParentBlocked={slice.items[beforeLast].isParentBlocked} - isParentNotFound={slice.items[beforeLast].isParentNotFound} - rootPost={slice.items[0].post} - /> - <FeedItem - key={slice.items[last]._reactKey} - post={slice.items[last].post} - record={slice.items[last].record} - reason={undefined} - feedContext={slice.feedContext} - parentAuthor={slice.items[last].parentAuthor} - showReplyTo={false} - moderation={slice.items[last].moderation} - isThreadParent={isThreadParentAt(slice.items, last)} - isThreadChild={isThreadChildAt(slice.items, last)} - isParentBlocked={slice.items[last].isParentBlocked} - isParentNotFound={slice.items[last].isParentNotFound} - isThreadLastChild - rootPost={slice.items[0].post} - /> - </> - ) - } - - return ( - <> - {slice.items.map((item, i) => ( - <FeedItem - key={item._reactKey} - post={slice.items[i].post} - record={slice.items[i].record} - reason={i === 0 ? slice.reason : undefined} - feedContext={slice.feedContext} - moderation={slice.items[i].moderation} - parentAuthor={slice.items[i].parentAuthor} - showReplyTo={i === 0} - isThreadParent={isThreadParentAt(slice.items, i)} - isThreadChild={isThreadChildAt(slice.items, i)} - isThreadLastChild={ - isThreadChildAt(slice.items, i) && slice.items.length === i + 1 - } - isParentBlocked={slice.items[i].isParentBlocked} - isParentNotFound={slice.items[i].isParentNotFound} - hideTopBorder={hideTopBorder && i === 0} - rootPost={slice.items[0].post} - /> - ))} - </> - ) -} -FeedSlice = memo(FeedSlice) -export {FeedSlice} - -function ViewFullThread({uri}: {uri: string}) { - const pal = usePalette('default') - const itemHref = React.useMemo(() => { - const urip = new AtUri(uri) - return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) - }, [uri]) - - return ( - <Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback> - <View style={styles.viewFullThreadDots}> - <Svg width="4" height="40"> - <Line - x1="2" - y1="0" - x2="2" - y2="15" - stroke={pal.colors.replyLine} - strokeWidth="2" - /> - <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> - <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> - <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> - </Svg> - </View> - - <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> - <Trans>View full thread</Trans> - </Text> - </Link> - ) -} - -const styles = StyleSheet.create({ - viewFullThread: { - flexDirection: 'row', - gap: 10, - paddingLeft: 18, - }, - viewFullThreadDots: { - width: 42, - alignItems: 'center', - }, -}) - -function isThreadParentAt<T>(arr: Array<T>, i: number) { - if (arr.length === 1) { - return false - } - return i < arr.length - 1 -} - -function isThreadChildAt<T>(arr: Array<T>, i: number) { - if (arr.length === 1) { - return false - } - return i > 0 -} diff --git a/src/view/com/posts/ViewFullThread.tsx b/src/view/com/posts/ViewFullThread.tsx new file mode 100644 index 000000000..0b347f22c --- /dev/null +++ b/src/view/com/posts/ViewFullThread.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Svg, {Circle, Line} from 'react-native-svg' +import {AtUri} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {usePalette} from '#/lib/hooks/usePalette' +import {makeProfileLink} from '#/lib/routes/links' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' + +export function ViewFullThread({uri}: {uri: string}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const pal = usePalette('default') + const itemHref = React.useMemo(() => { + const urip = new AtUri(uri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [uri]) + + return ( + <Link + style={[styles.viewFullThread]} + href={itemHref} + asAnchor + noFeedback + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut}> + <SubtleWebHover + hover={hover} + // adjust position for visual alignment - the actual box has lots of top padding and not much bottom padding -sfn + style={{top: 8, bottom: -5}} + /> + <View style={styles.viewFullThreadDots}> + <Svg width="4" height="40"> + <Line + x1="2" + y1="0" + x2="2" + y2="15" + stroke={pal.colors.replyLine} + strokeWidth="2" + /> + <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} /> + <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} /> + </Svg> + </View> + + <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}> + <Trans>View full thread</Trans> + </Text> + </Link> + ) +} + +const styles = StyleSheet.create({ + viewFullThread: { + flexDirection: 'row', + gap: 10, + paddingLeft: 18, + }, + viewFullThreadDots: { + width: 42, + alignItems: 'center', + }, +}) |