Tree view threads experiment (#1480)

* Add tree-view experiment to threads

* Fix typo

* Remove extra minimalshellmode call

* Fix to parent line rendering

* Fix extra border

* Some ui cleanup
zio/stable
Paul Frazee 2023-09-19 19:08:11 -07:00 committed by GitHub
parent d2c253a284
commit 1af8e83d53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 178 additions and 53 deletions

View File

@ -58,6 +58,7 @@ export class PreferencesModel {
homeFeedMergeFeedEnabled: boolean = false homeFeedMergeFeedEnabled: boolean = false
threadDefaultSort: string = 'oldest' threadDefaultSort: string = 'oldest'
threadFollowedUsersFirst: boolean = true threadFollowedUsersFirst: boolean = true
threadTreeViewEnabled: boolean = false
requireAltTextEnabled: boolean = false requireAltTextEnabled: boolean = false
// used to linearize async modifications to state // used to linearize async modifications to state
@ -91,6 +92,7 @@ export class PreferencesModel {
homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
threadDefaultSort: this.threadDefaultSort, threadDefaultSort: this.threadDefaultSort,
threadFollowedUsersFirst: this.threadFollowedUsersFirst, threadFollowedUsersFirst: this.threadFollowedUsersFirst,
threadTreeViewEnabled: this.threadTreeViewEnabled,
requireAltTextEnabled: this.requireAltTextEnabled, requireAltTextEnabled: this.requireAltTextEnabled,
} }
} }
@ -202,13 +204,20 @@ export class PreferencesModel {
) { ) {
this.threadDefaultSort = v.threadDefaultSort this.threadDefaultSort = v.threadDefaultSort
} }
// check if tread followed-users-first is enabled in preferences, then hydrate // check if thread followed-users-first is enabled in preferences, then hydrate
if ( if (
hasProp(v, 'threadFollowedUsersFirst') && hasProp(v, 'threadFollowedUsersFirst') &&
typeof v.threadFollowedUsersFirst === 'boolean' typeof v.threadFollowedUsersFirst === 'boolean'
) { ) {
this.threadFollowedUsersFirst = v.threadFollowedUsersFirst this.threadFollowedUsersFirst = v.threadFollowedUsersFirst
} }
// check if thread treeview is enabled in preferences, then hydrate
if (
hasProp(v, 'threadTreeViewEnabled') &&
typeof v.threadTreeViewEnabled === 'boolean'
) {
this.threadTreeViewEnabled = v.threadTreeViewEnabled
}
// check if requiring alt text is enabled in preferences, then hydrate // check if requiring alt text is enabled in preferences, then hydrate
if ( if (
hasProp(v, 'requireAltTextEnabled') && hasProp(v, 'requireAltTextEnabled') &&
@ -524,6 +533,10 @@ export class PreferencesModel {
this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst
} }
toggleThreadTreeViewEnabled() {
this.threadTreeViewEnabled = !this.threadTreeViewEnabled
}
toggleRequireAltTextEnabled() { toggleRequireAltTextEnabled() {
this.requireAltTextEnabled = !this.requireAltTextEnabled this.requireAltTextEnabled = !this.requireAltTextEnabled
} }

View File

@ -55,6 +55,7 @@ const LOAD_MORE = {
const BOTTOM_COMPONENT = { const BOTTOM_COMPONENT = {
_reactKey: '__bottom_component__', _reactKey: '__bottom_component__',
_isHighlightedPost: false, _isHighlightedPost: false,
_showBorder: true,
} }
type YieldedItem = type YieldedItem =
| PostThreadItemModel | PostThreadItemModel
@ -69,10 +70,12 @@ export const PostThread = observer(function PostThread({
uri, uri,
view, view,
onPressReply, onPressReply,
treeView,
}: { }: {
uri: string uri: string
view: PostThreadModel view: PostThreadModel
onPressReply: () => void onPressReply: () => void
treeView: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {isTablet} = useWebMediaQueries() const {isTablet} = useWebMediaQueries()
@ -99,6 +102,13 @@ export const PostThread = observer(function PostThread({
} }
return [] return []
}, [view.isLoadingFromCache, view.thread, maxVisible]) }, [view.isLoadingFromCache, view.thread, maxVisible])
const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
const showBottomBorder =
!treeView ||
// in the treeview, only show the bottom border
// if there are replies under the highlighted posts
posts.findLast(v => v instanceof PostThreadItemModel) !==
posts[highlightedPostIndex]
useSetTitle( useSetTitle(
view.thread?.postRecord && view.thread?.postRecord &&
`${sanitizeDisplayName( `${sanitizeDisplayName(
@ -135,17 +145,16 @@ export const PostThread = observer(function PostThread({
return return
} }
const index = posts.findIndex(post => post._isHighlightedPost) if (highlightedPostIndex !== -1) {
if (index !== -1) {
ref.current?.scrollToIndex({ ref.current?.scrollToIndex({
index, index: highlightedPostIndex,
animated: false, animated: false,
viewPosition: 0, viewPosition: 0,
}) })
hasScrolledIntoView.current = true hasScrolledIntoView.current = true
} }
}, [ }, [
posts, highlightedPostIndex,
view.hasContent, view.hasContent,
view.isFromCache, view.isFromCache,
view.isLoadingFromCache, view.isLoadingFromCache,
@ -184,7 +193,14 @@ export const PostThread = observer(function PostThread({
</View> </View>
) )
} else if (item === REPLY_PROMPT) { } else if (item === REPLY_PROMPT) {
return <ComposePrompt onPressCompose={onPressReply} /> return (
<View
style={
treeView && [pal.border, {borderBottomWidth: 1, marginBottom: 6}]
}>
{isDesktopWeb && <ComposePrompt onPressCompose={onPressReply} />}
</View>
)
} else if (item === DELETED) { } else if (item === DELETED) {
return ( return (
<View style={[pal.border, pal.viewLight, styles.itemContainer]}> <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
@ -224,7 +240,18 @@ export const PostThread = observer(function PostThread({
// due to some complexities with how flatlist works, this is the easiest way // due to some complexities with how flatlist works, this is the easiest way
// I could find to get a border positioned directly under the last item // I could find to get a border positioned directly under the last item
// -prf // -prf
return <View style={[pal.border, styles.bottomSpacer]} /> return (
<View
style={[
{height: 400},
showBottomBorder && {
borderTopWidth: 1,
borderColor: pal.colors.border,
},
treeView && {marginTop: 10},
]}
/>
)
} else if (item === CHILD_SPINNER) { } else if (item === CHILD_SPINNER) {
return ( return (
<View style={styles.childSpinner}> <View style={styles.childSpinner}>
@ -240,12 +267,13 @@ export const PostThread = observer(function PostThread({
item={item} item={item}
onPostReply={onRefresh} onPostReply={onRefresh}
hasPrecedingItem={prev?._showChildReplyLine} hasPrecedingItem={prev?._showChildReplyLine}
treeView={treeView}
/> />
) )
} }
return <></> return <></>
}, },
[onRefresh, onPressReply, pal, posts, isTablet], [onRefresh, onPressReply, pal, posts, isTablet, treeView, showBottomBorder],
) )
// loading // loading
@ -377,7 +405,7 @@ function* flattenThread(
} }
} }
yield post yield post
if (isDesktopWeb && post._isHighlightedPost) { if (post._isHighlightedPost) {
yield REPLY_PROMPT yield REPLY_PROMPT
} }
if (post.replies?.length) { if (post.replies?.length) {
@ -411,8 +439,4 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
childSpinner: {}, childSpinner: {},
bottomSpacer: {
height: 400,
borderTopWidth: 1,
},
}) })

View File

@ -35,15 +35,18 @@ import {formatCount} from '../util/numeric/format'
import {TimeElapsed} from 'view/com/util/TimeElapsed' import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
export const PostThreadItem = observer(function PostThreadItem({ export const PostThreadItem = observer(function PostThreadItem({
item, item,
onPostReply, onPostReply,
hasPrecedingItem, hasPrecedingItem,
treeView,
}: { }: {
item: PostThreadItemModel item: PostThreadItemModel
onPostReply: () => void onPostReply: () => void
hasPrecedingItem: boolean hasPrecedingItem: boolean
treeView: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@ -389,25 +392,28 @@ export const PostThreadItem = observer(function PostThreadItem({
</> </>
) )
} else { } else {
const isThreadedChild = treeView && item._depth > 0
return ( return (
<> <PostOuterWrapper
item={item}
hasPrecedingItem={hasPrecedingItem}
treeView={treeView}>
<PostHider <PostHider
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${item.post.author.handle}`}
href={itemHref} href={itemHref}
style={[ style={[pal.view]}
styles.outer,
pal.border,
pal.view,
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor,
]}
moderation={item.moderation.content}> moderation={item.moderation.content}>
<PostSandboxWarning /> <PostSandboxWarning />
<View <View
style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}> style={{
flexDirection: 'row',
gap: 10,
paddingLeft: 8,
height: isThreadedChild ? 8 : 16,
}}>
<View style={{width: 52}}> <View style={{width: 52}}>
{item._showParentReplyLine && ( {!isThreadedChild && item._showParentReplyLine && (
<View <View
style={[ style={[
styles.replyLine, styles.replyLine,
@ -431,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({
]}> ]}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={52} size={isThreadedChild ? 24 : 52}
did={item.post.author.did} did={item.post.author.did}
handle={item.post.author.handle} handle={item.post.author.handle}
avatar={item.post.author.avatar} avatar={item.post.author.avatar}
@ -444,7 +450,9 @@ export const PostThreadItem = observer(function PostThreadItem({
styles.replyLine, styles.replyLine,
{ {
flexGrow: 1, flexGrow: 1,
backgroundColor: pal.colors.replyLine, backgroundColor: isThreadedChild
? pal.colors.border
: pal.colors.replyLine,
marginTop: 4, marginTop: 4,
}, },
]} ]}
@ -464,7 +472,11 @@ export const PostThreadItem = observer(function PostThreadItem({
style={styles.alert} style={styles.alert}
/> />
{item.richText?.text ? ( {item.richText?.text ? (
<View style={styles.postTextContainer}> <View
style={[
styles.postTextContainer,
isThreadedChild && {paddingTop: 2},
]}>
<RichText <RichText
type="post-text" type="post-text"
richText={item.richText} richText={item.richText}
@ -508,30 +520,84 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</View> </View>
</View> </View>
{item._hasMore ? (
<Link
style={[
styles.loadMore,
{
paddingLeft: treeView ? 44 : 70,
paddingTop: 0,
paddingBottom: treeView ? 4 : 12,
},
]}
href={itemHref}
title={itemTitle}
noFeedback>
<Text type="sm-medium" style={pal.textLight}>
More
</Text>
<FontAwesomeIcon
icon="angle-right"
color={pal.colors.textLight}
size={14}
/>
</Link>
) : undefined}
</PostHider> </PostHider>
{item._hasMore ? ( </PostOuterWrapper>
<Link
style={[
styles.loadMore,
{borderTopColor: pal.colors.border},
pal.view,
]}
href={itemHref}
title={itemTitle}
noFeedback>
<Text style={pal.link}>Continue thread...</Text>
<FontAwesomeIcon
icon="angle-right"
style={pal.link as FontAwesomeIconStyle}
size={18}
/>
</Link>
) : undefined}
</>
) )
} }
}) })
function PostOuterWrapper({
item,
hasPrecedingItem,
treeView,
children,
}: React.PropsWithChildren<{
item: PostThreadItemModel
hasPrecedingItem: boolean
treeView: boolean
}>) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
if (treeView && item._depth > 0) {
return (
<View
style={[
pal.view,
styles.cursor,
{flexDirection: 'row', paddingLeft: 10},
]}>
{Array.from(Array(item._depth - 1)).map((_, n: number) => (
<View
key={`${item.uri}-padding-${n}`}
style={{
borderLeftWidth: 2,
borderLeftColor: pal.colors.border,
marginLeft: 19,
paddingLeft: isMobile ? 0 : 4,
}}
/>
))}
<View style={{flex: 1}}>{children}</View>
</View>
)
}
return (
<View
style={[
styles.outer,
pal.view,
pal.border,
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor,
]}>
{children}
</View>
)
}
function ExpandedPostDetails({ function ExpandedPostDetails({
post, post,
needsTranslation, needsTranslation,
@ -600,7 +666,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
paddingBottom: 8, paddingBottom: 4,
paddingRight: 10, paddingRight: 10,
}, },
postTextLargeContainer: { postTextLargeContainer: {
@ -629,11 +695,10 @@ const styles = StyleSheet.create({
}, },
loadMore: { loadMore: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center',
borderTopWidth: 1, justifyContent: 'flex-start',
paddingLeft: 80, gap: 4,
paddingRight: 20, paddingHorizontal: 20,
paddingVertical: 12,
}, },
replyLine: { replyLine: {
width: 2, width: 2,

View File

@ -45,6 +45,7 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile' import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
import {faFlask} from '@fortawesome/free-solid-svg-icons'
import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk' import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@ -144,6 +145,7 @@ export function setup() {
farEyeSlash, farEyeSlash,
faFaceSmile, faFaceSmile,
faFire, faFire,
faFlask,
faFloppyDisk, faFloppyDisk,
faGear, faGear,
faGlobe, faGlobe,

View File

@ -74,6 +74,7 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => {
uri={uri} uri={uri}
view={view} view={view}
onPressReply={onPressReply} onPressReply={onPressReply}
treeView={store.preferences.threadTreeViewEnabled}
/> />
</View> </View>
{isMobile && ( {isMobile && (

View File

@ -1,6 +1,7 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Slider} from '@miblanchard/react-native-slider' import {Slider} from '@miblanchard/react-native-slider'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -158,11 +159,12 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
<View style={[pal.viewLight, styles.card]}> <View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}> <Text type="title-sm" style={[pal.text, s.pb5]}>
Show Posts from My Feeds (Experimental) <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show
Posts from My Feeds
</Text> </Text>
<Text style={[pal.text, s.pb10]}> <Text style={[pal.text, s.pb10]}>
Set this setting to "Yes" to show samples of your saved feeds in Set this setting to "Yes" to show samples of your saved feeds in
your following feed. your following feed. This is an experimental feature.
</Text> </Text>
<ToggleButton <ToggleButton
type="default-light" type="default-light"

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
@ -78,6 +79,23 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({
onPress={store.preferences.toggleThreadFollowedUsersFirst} onPress={store.preferences.toggleThreadFollowedUsersFirst}
/> />
</View> </View>
<View style={[pal.viewLight, styles.card]}>
<Text type="title-sm" style={[pal.text, s.pb5]}>
<FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded
Mode
</Text>
<Text style={[pal.text, s.pb10]}>
Set this setting to "Yes" to show replies in a threaded view. This
is an experimental feature.
</Text>
<ToggleButton
type="default-light"
label={store.preferences.threadTreeViewEnabled ? 'Yes' : 'No'}
isSelected={store.preferences.threadTreeViewEnabled}
onPress={store.preferences.toggleThreadTreeViewEnabled}
/>
</View>
</View> </View>
</ScrollView> </ScrollView>