Always show the header on post threads on native (#4254)

* always show header on native

* ALF ALF ALF

* rm offset for top border

* wrap in a `CenteredView`

* use `CenteredView`'s side borders

* account for loading state on web

* move `isTabletOrMobile`

* hide top border on first post in list

* show border if parents are loading

* don't show top border for deleted or blocked posts

* hide top border for hidden replies

* Rm root post top border

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Hailey 2024-05-29 20:28:32 -07:00 committed by GitHub
parent 9628070e52
commit 9edb487949
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 194 additions and 190 deletions

View File

@ -1,5 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, {useEffect, useRef} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native' import {useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated' import {runOnJS} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@ -22,15 +22,16 @@ import {
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
import {ComposePrompt} from '../composer/Prompt' import {ComposePrompt} from '../composer/Prompt'
import {List, ListMethods} from '../util/List' import {List, ListMethods} from '../util/List'
import {Text} from '../util/text/Text'
import {ViewHeader} from '../util/ViewHeader' import {ViewHeader} from '../util/ViewHeader'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
@ -45,7 +46,6 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = {
minIndexForVisible: 0, minIndexForVisible: 0,
} }
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const REPLY_PROMPT = {_reactKey: '__reply__'} const REPLY_PROMPT = {_reactKey: '__reply__'}
const LOAD_MORE = {_reactKey: '__load_more__'} const LOAD_MORE = {_reactKey: '__load_more__'}
const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
@ -66,7 +66,6 @@ type YieldedItem =
type RowItem = type RowItem =
| YieldedItem | YieldedItem
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
| typeof TOP_COMPONENT
| typeof REPLY_PROMPT | typeof REPLY_PROMPT
| typeof LOAD_MORE | typeof LOAD_MORE
@ -91,7 +90,7 @@ export function PostThread({
}) { }) {
const {hasSession} = useSession() const {hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const t = useTheme()
const {isMobile, isTabletOrMobile} = useWebMediaQueries() const {isMobile, isTabletOrMobile} = useWebMediaQueries()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const {height: windowHeight} = useWindowDimensions() const {height: windowHeight} = useWindowDimensions()
@ -224,32 +223,21 @@ export function PostThread({
const {parents, highlightedPost, replies} = skeleton const {parents, highlightedPost, replies} = skeleton
let arr: RowItem[] = [] let arr: RowItem[] = []
if (highlightedPost.type === 'post') { if (highlightedPost.type === 'post') {
const isRoot = // We want to wait for parents to load before rendering.
!highlightedPost.parent && !highlightedPost.ctx.isParentLoading // If you add something here, you'll need to update both
if (isRoot) { // maintainVisibleContentPosition and onContentSizeChange
// No parents to load. // to "hold onto" the correct row instead of the first one.
arr.push(TOP_COMPONENT)
} else { if (!highlightedPost.ctx.isParentLoading && !deferParents) {
if (highlightedPost.ctx.isParentLoading || deferParents) { // When progressively revealing parents, rendering a placeholder
// We're loading parents of the highlighted post. // here will cause scrolling jumps. Don't add it unless you test it.
// In this case, we don't render anything above the post. // QT'ing this thread is a great way to test all the scrolling hacks:
// If you add something here, you'll need to update both // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one. // Everything is loaded
} else { let startIndex = Math.max(0, parents.length - maxParents)
// Everything is loaded for (let i = startIndex; i < parents.length; i++) {
let startIndex = Math.max(0, parents.length - maxParents) arr.push(parents[i])
if (startIndex === 0) {
arr.push(TOP_COMPONENT)
} else {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
}
for (let i = startIndex; i < parents.length; i++) {
arr.push(parents[i])
}
} }
} }
arr.push(highlightedPost) arr.push(highlightedPost)
@ -323,117 +311,100 @@ export function PostThread({
setMaxReplies(prev => prev + 50) setMaxReplies(prev => prev + 50)
}, [isFetching, maxReplies, posts.length]) }, [isFetching, maxReplies, posts.length])
const renderItem = React.useCallback( const hasParents =
({item, index}: {item: RowItem; index: number}) => { skeleton?.highlightedPost?.type === 'post' &&
if (item === TOP_COMPONENT) { (skeleton.highlightedPost.ctx.isParentLoading ||
return isTabletOrMobile ? ( Boolean(skeleton?.parents && skeleton.parents.length > 0))
<ViewHeader const showHeader =
title={_(msg({message: `Post`, context: 'description'}))} isNative || (isTabletOrMobile && (!hasParents || !isFetching))
/>
) : null const renderItem = ({item, index}: {item: RowItem; index: number}) => {
} else if (item === REPLY_PROMPT && hasSession) { if (item === REPLY_PROMPT && hasSession) {
return ( return (
<View> <View>
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />} {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
</View> </View>
) )
} else if (item === SHOW_HIDDEN_REPLIES) { } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
return ( return (
<PostThreadShowHiddenReplies <PostThreadShowHiddenReplies
type="hidden" type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
onPress={() => onPress={() =>
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider) setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
}
hideTopBorder={index === 0}
/>
)
} else if (isThreadNotFound(item)) {
return (
<View
style={[
a.p_lg,
index !== 0 && a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Deleted post.</Trans>
</Text>
</View>
)
} else if (isThreadBlocked(item)) {
return (
<View
style={[
a.p_lg,
index !== 0 && a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Blocked post.</Trans>
</Text>
</View>
)
} else if (isThreadPost(item)) {
const prev = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
const next = isThreadPost(posts[index + 1])
? (posts[index + 1] as ThreadPost)
: undefined
const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
const showParentReplyLine =
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
const hasUnrevealedParents =
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
return (
<View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
<PostThreadItem
post={item.post}
record={item.record}
moderation={threadModerationCache.get(item)}
treeView={treeView}
depth={item.ctx.depth}
prevPost={prev}
nextPost={next}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
showChildReplyLine={showChildReplyLine}
showParentReplyLine={showParentReplyLine}
hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
overrideBlur={
hiddenRepliesState ===
HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0
} }
onPostReply={refetch}
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
/> />
) </View>
} else if (item === SHOW_MUTED_REPLIES) { )
return ( }
<PostThreadShowHiddenReplies return null
type="muted" }
onPress={() =>
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
}
/>
)
} else if (isThreadNotFound(item)) {
return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
<Text type="lg-bold" style={pal.textLight}>
<Trans>Deleted post.</Trans>
</Text>
</View>
)
} else if (isThreadBlocked(item)) {
return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}>
<Text type="lg-bold" style={pal.textLight}>
<Trans>Blocked post.</Trans>
</Text>
</View>
)
} else if (isThreadPost(item)) {
const prev = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
const next = isThreadPost(posts[index + 1])
? (posts[index + 1] as ThreadPost)
: undefined
const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
const showParentReplyLine =
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
const hasUnrevealedParents =
index === 0 &&
skeleton?.parents &&
maxParents < skeleton.parents.length
return (
<View
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
<PostThreadItem
post={item.post}
record={item.record}
moderation={threadModerationCache.get(item)}
treeView={treeView}
depth={item.ctx.depth}
prevPost={prev}
nextPost={next}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
showChildReplyLine={showChildReplyLine}
showParentReplyLine={showParentReplyLine}
hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
overrideBlur={
hiddenRepliesState ===
HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0
}
onPostReply={refetch}
/>
</View>
)
}
return null
},
[
hasSession,
isTabletOrMobile,
_,
isMobile,
onPressReply,
pal.border,
pal.viewLight,
pal.textLight,
posts,
skeleton?.parents,
maxParents,
deferParents,
treeView,
refetch,
threadModerationCache,
hiddenRepliesState,
setHiddenRepliesState,
],
)
if (!thread || !preferences || error) { if (!thread || !preferences || error) {
return ( return (
@ -449,39 +420,49 @@ export function PostThread({
} }
return ( return (
<ScrollProvider onMomentumEnd={onMomentumEnd}> <CenteredView style={[a.flex_1]} sideBorders={true}>
<List {showHeader && (
ref={ref} <ViewHeader
data={posts} title={_(msg({message: `Post`, context: 'description'}))}
renderItem={renderItem} showBorder
keyExtractor={keyExtractor} />
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} )}
onStartReached={onStartReached}
onEndReached={onEndReached} <ScrollProvider onMomentumEnd={onMomentumEnd}>
onEndReachedThreshold={2} <List
onScrollToTop={onScrollToTop} ref={ref}
maintainVisibleContentPosition={ data={posts}
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined renderItem={renderItem}
} keyExtractor={keyExtractor}
// @ts-ignore our .web version only -prf onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
desktopFixedHeight onStartReached={onStartReached}
removeClippedSubviews={isAndroid ? false : undefined} onEndReached={onEndReached}
ListFooterComponent={ onEndReachedThreshold={2}
<ListFooter onScrollToTop={onScrollToTop}
// Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on maintainVisibleContentPosition={
// initial render isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
isFetchingNextPage={isFetching} }
error={cleanError(threadError)} // @ts-ignore our .web version only -prf
onRetry={refetch} desktopFixedHeight
// 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to removeClippedSubviews={isAndroid ? false : undefined}
// work without causing weird jumps on web or glitches on native ListFooterComponent={
height={windowHeight - 200} <ListFooter
/> // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
} // initial render
initialNumToRender={initialNumToRender} isFetchingNextPage={isFetching}
windowSize={11} error={cleanError(threadError)}
/> onRetry={refetch}
</ScrollProvider> // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
// work without causing weird jumps on web or glitches on native
height={windowHeight - 200}
/>
}
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
/>
</ScrollProvider>
</CenteredView>
) )
} }
@ -630,11 +611,3 @@ function hasBranchingReplies(node?: ThreadNode) {
} }
return true return true
} }
const styles = StyleSheet.create({
itemContainer: {
borderTopWidth: 1,
paddingHorizontal: 18,
paddingVertical: 18,
},
})

View File

@ -65,6 +65,7 @@ export function PostThreadItem({
hasPrecedingItem, hasPrecedingItem,
overrideBlur, overrideBlur,
onPostReply, onPostReply,
hideTopBorder,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
@ -80,6 +81,7 @@ export function PostThreadItem({
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: () => void
hideTopBorder?: boolean
}) { }) {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
@ -91,7 +93,7 @@ export function PostThreadItem({
[record], [record],
) )
if (postShadowed === POST_TOMBSTONE) { if (postShadowed === POST_TOMBSTONE) {
return <PostThreadItemDeleted /> return <PostThreadItemDeleted hideTopBorder={hideTopBorder} />
} }
if (richText && moderation) { if (richText && moderation) {
return ( return (
@ -113,16 +115,25 @@ export function PostThreadItem({
hasPrecedingItem={hasPrecedingItem} hasPrecedingItem={hasPrecedingItem}
overrideBlur={overrideBlur} overrideBlur={overrideBlur}
onPostReply={onPostReply} onPostReply={onPostReply}
hideTopBorder={hideTopBorder}
/> />
) )
} }
return null return null
} }
function PostThreadItemDeleted() { function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> <View
style={[
styles.outer,
pal.border,
pal.view,
s.p20,
s.flexRow,
hideTopBorder && styles.noTopBorder,
]}>
<FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
<Text style={[pal.textLight, s.ml10]}> <Text style={[pal.textLight, s.ml10]}>
<Trans>This post has been deleted.</Trans> <Trans>This post has been deleted.</Trans>
@ -147,6 +158,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem, hasPrecedingItem,
overrideBlur, overrideBlur,
onPostReply, onPostReply,
hideTopBorder,
}: { }: {
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
@ -163,6 +175,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: () => void
hideTopBorder?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
@ -237,7 +250,7 @@ let PostThreadItemLoaded = ({
styles.replyLine, styles.replyLine,
{ {
flexGrow: 1, flexGrow: 1,
backgroundColor: pal.colors.border, backgroundColor: pal.colors.replyLine,
}, },
]} ]}
/> />
@ -247,7 +260,14 @@ let PostThreadItemLoaded = ({
<View <View
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[
styles.outer,
styles.outerHighlighted,
pal.border,
pal.view,
rootUri === post.uri && styles.outerHighlightedRoot,
hideTopBorder && styles.noTopBorder,
]}
accessible={false}> accessible={false}>
<View style={[styles.layout]}> <View style={[styles.layout]}>
<View style={[styles.layoutAvi, {paddingBottom: 8}]}> <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
@ -395,7 +415,8 @@ let PostThreadItemLoaded = ({
depth={depth} depth={depth}
showParentReplyLine={!!showParentReplyLine} showParentReplyLine={!!showParentReplyLine}
treeView={treeView} treeView={treeView}
hasPrecedingItem={hasPrecedingItem}> hasPrecedingItem={hasPrecedingItem}
hideTopBorder={hideTopBorder}>
<PostHider <PostHider
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
href={postHref} href={postHref}
@ -574,6 +595,7 @@ function PostOuterWrapper({
depth, depth,
showParentReplyLine, showParentReplyLine,
hasPrecedingItem, hasPrecedingItem,
hideTopBorder,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
@ -581,6 +603,7 @@ function PostOuterWrapper({
depth: number depth: number
showParentReplyLine: boolean showParentReplyLine: boolean
hasPrecedingItem: boolean hasPrecedingItem: boolean
hideTopBorder?: boolean
}>) { }>) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
@ -617,6 +640,7 @@ function PostOuterWrapper({
styles.outer, styles.outer,
pal.border, pal.border,
showParentReplyLine && hasPrecedingItem && styles.noTopBorder, showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
hideTopBorder && styles.noTopBorder,
styles.cursor, styles.cursor,
]}> ]}>
{children} {children}
@ -677,10 +701,15 @@ const styles = StyleSheet.create({
paddingLeft: 8, paddingLeft: 8,
}, },
outerHighlighted: { outerHighlighted: {
paddingTop: 16, borderTopWidth: 0,
paddingTop: 4,
paddingLeft: 8, paddingLeft: 8,
paddingRight: 8, paddingRight: 8,
}, },
outerHighlightedRoot: {
borderTopWidth: 1,
paddingTop: 16,
},
noTopBorder: { noTopBorder: {
borderTopWidth: 0, borderTopWidth: 0,
}, },

View File

@ -11,9 +11,11 @@ import {Text} from '#/components/Typography'
export function PostThreadShowHiddenReplies({ export function PostThreadShowHiddenReplies({
type, type,
onPress, onPress,
hideTopBorder,
}: { }: {
type: 'hidden' | 'muted' type: 'hidden' | 'muted'
onPress: () => void onPress: () => void
hideTopBorder?: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@ -31,7 +33,7 @@ export function PostThreadShowHiddenReplies({
a.gap_sm, a.gap_sm,
a.py_lg, a.py_lg,
a.px_xl, a.px_xl,
a.border_t, !hideTopBorder && a.border_t,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}> ]}>