Improve thread rendering

zio/stable
Paul Frazee 2022-12-18 18:54:05 -06:00
parent 69b86255c6
commit ae3099dfca
5 changed files with 194 additions and 101 deletions

View File

@ -17,6 +17,7 @@ let _idCounter = 0
type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
type FeedItemWithThreadMeta = FeedItem & { type FeedItemWithThreadMeta = FeedItem & {
_isThreadParent?: boolean _isThreadParent?: boolean
_isThreadChildElided?: boolean
_isThreadChild?: boolean _isThreadChild?: boolean
} }
@ -34,6 +35,7 @@ export class FeedItemModel implements GetTimeline.FeedItem {
// ui state // ui state
_reactKey: string = '' _reactKey: string = ''
_isThreadParent: boolean = false _isThreadParent: boolean = false
_isThreadChildElided: boolean = false
_isThreadChild: boolean = false _isThreadChild: boolean = false
// data // data
@ -70,6 +72,7 @@ export class FeedItemModel implements GetTimeline.FeedItem {
this.copy(v) this.copy(v)
this._isThreadParent = v._isThreadParent || false this._isThreadParent = v._isThreadParent || false
this._isThreadChild = v._isThreadChild || false this._isThreadChild = v._isThreadChild || false
this._isThreadChildElided = v._isThreadChildElided || false
} }
copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) { copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) {
@ -469,15 +472,7 @@ export class FeedModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
// HACK 1 const reorgedFeed = preprocessFeed(res.data.feed)
// rearrange the posts to represent threads
// (should be done on the server)
// -prf
// HACK 2
// deduplicate posts on the home feed
// (should be done on the server)
// -prf
const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home')
const promises = [] const promises = []
const toAppend: FeedItemModel[] = [] const toAppend: FeedItemModel[] = []
@ -569,38 +564,78 @@ export class FeedModel {
} }
} }
function preprocessFeed( interface Slice {
feed: FeedItem[], index: number
dedup: boolean, length: number
): FeedItemWithThreadMeta[] { }
// DEBUG function preprocessFeed(feed: FeedItem[]): FeedItemWithThreadMeta[] {
// this has been temporarily disabled to see if it's the cause of some bugs const reorg: FeedItemWithThreadMeta[] = []
// if the issues go away, we know this was the cause
// -prf
return feed
// const reorg: FeedItemWithThreadMeta[] = []
// for (let i = feed.length - 1; i >= 0; i--) {
// const item = feed[i] as FeedItemWithThreadMeta
// if (dedup) { // phase one: identify threads and reorganize them into the feed so
// if (reorg.find(item2 => item2.uri === item.uri)) { // that they are in order and marked as part of a thread
// continue for (let i = feed.length - 1; i >= 0; i--) {
// } const item = feed[i] as FeedItemWithThreadMeta
// }
// const selfReplyUri = getSelfReplyUri(item) const selfReplyUri = getSelfReplyUri(item)
// if (selfReplyUri) { if (selfReplyUri) {
// const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri) const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
// if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) { if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
// reorg[parentIndex]._isThreadParent = true reorg[parentIndex]._isThreadParent = true
// item._isThreadChild = true item._isThreadChild = true
// reorg.splice(parentIndex + 1, 0, item) reorg.splice(parentIndex + 1, 0, item)
// continue continue
// } }
// } }
// reorg.unshift(item) reorg.unshift(item)
// } }
// return reorg
// phase two: identify the positions of the threads
let activeSlice = -1
let threadSlices: Slice[] = []
for (let i = 0; i < reorg.length; i++) {
const item = reorg[i] as FeedItemWithThreadMeta
if (activeSlice === -1) {
if (item._isThreadParent) {
activeSlice = i
}
} else {
if (!item._isThreadChild) {
threadSlices.push({index: activeSlice, length: i - activeSlice})
activeSlice = -1
}
}
}
if (activeSlice !== -1) {
threadSlices.push({index: activeSlice, length: reorg.length - activeSlice})
}
// phase three: reorder the feed so that the timestamp of the
// last post in a thread establishes its ordering
for (const slice of threadSlices) {
const removed: FeedItemWithThreadMeta[] = reorg.splice(
slice.index,
slice.length,
)
const targetDate = new Date(removed[removed.length - 1].indexedAt)
const newIndex = reorg.findIndex(
item => new Date(item.indexedAt) < targetDate,
)
reorg.splice(newIndex, 0, ...removed)
slice.index = newIndex
}
// phase four: compress any threads that are longer than 3 posts
let removedCount = 0
for (const slice of threadSlices) {
if (slice.length > 3) {
reorg.splice(slice.index - removedCount + 1, slice.length - 3)
reorg[slice.index - removedCount]._isThreadChildElided = true
console.log(reorg[slice.index - removedCount])
removedCount += slice.length - 3
}
}
return reorg
} }
function getSelfReplyUri( function getSelfReplyUri(

View File

@ -48,6 +48,7 @@ export class PostThreadViewPostModel implements GetPostThread.Post {
_reactKey: string = '' _reactKey: string = ''
_depth = 0 _depth = 0
_isHighlightedPost = false _isHighlightedPost = false
_hasMore = false
// data // data
$type: string = '' $type: string = ''

View File

@ -90,14 +90,17 @@ export const PostThread = observer(function PostThread({
function* flattenThread( function* flattenThread(
post: PostThreadViewPostModel, post: PostThreadViewPostModel,
isAscending = false,
): Generator<PostThreadViewPostModel, void> { ): Generator<PostThreadViewPostModel, void> {
if (post.parent) { if (post.parent) {
yield* flattenThread(post.parent) yield* flattenThread(post.parent, true)
} }
yield post yield post
if (post.replies?.length) { if (post.replies?.length) {
for (const reply of post.replies) { for (const reply of post.replies) {
yield* flattenThread(reply) yield* flattenThread(reply)
} }
} else if (!isAscending && !post.parent && post.replyCount > 0) {
post._hasMore = true
} }
} }

View File

@ -226,71 +226,82 @@ export const PostThreadItem = observer(function PostThreadItem({
) )
} else { } else {
return ( return (
<Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback> <>
{!item.replyingTo && item.record.reply && ( <Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback>
<View style={styles.parentReplyLine} /> {!item.replyingTo && item.record.reply && (
)} <View style={styles.parentReplyLine} />
{item.replies?.length !== 0 && <View style={styles.childReplyLine} />} )}
{item.replyingTo ? ( {item.replies?.length !== 0 && <View style={styles.childReplyLine} />}
<View style={styles.replyingTo}> {item.replyingTo ? (
<View style={styles.replyingToLine} /> <View style={styles.replyingTo}>
<View style={styles.replyingToAvatar}> <View style={styles.replyingToLine} />
<UserAvatar <View style={styles.replyingToAvatar}>
handle={item.replyingTo.author.handle} <UserAvatar
displayName={item.replyingTo.author.displayName} handle={item.replyingTo.author.handle}
avatar={item.replyingTo.author.avatar} displayName={item.replyingTo.author.displayName}
size={30} avatar={item.replyingTo.author.avatar}
size={30}
/>
</View>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.replyingTo.text}
</Text>
</View>
) : undefined}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={[styles.postText]}
/>
</View>
<PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/> />
</View> </View>
<Text style={styles.replyingToText} numberOfLines={2}>
{item.replyingTo.text}
</Text>
</View> </View>
</Link>
{item._hasMore ? (
<Link
style={styles.loadMore}
href={itemHref}
title={itemTitle}
noFeedback>
<Text style={styles.loadMoreText}>Load more</Text>
</Link>
) : undefined} ) : undefined}
<View style={styles.layout}> </>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={authorTitle}>
<UserAvatar
size={50}
displayName={item.author.displayName}
handle={item.author.handle}
avatar={item.author.avatar}
/>
</Link>
</View>
<View style={styles.layoutContent}>
<PostMeta
itemHref={itemHref}
itemTitle={itemTitle}
authorHref={authorHref}
authorHandle={item.author.handle}
authorDisplayName={item.author.displayName}
timestamp={item.indexedAt}
isAuthor={item.author.did === store.me.did}
onCopyPostText={onCopyPostText}
onDeletePost={onDeletePost}
/>
<View style={styles.postTextContainer}>
<RichText
text={record.text}
entities={record.entities}
style={[styles.postText]}
/>
</View>
<PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleUpvote={onPressToggleUpvote}
/>
</View>
</View>
</Link>
) )
} }
}) })
@ -398,4 +409,16 @@ const styles = StyleSheet.create({
expandedInfoItem: { expandedInfoItem: {
marginRight: 10, marginRight: 10,
}, },
loadMore: {
paddingLeft: 28,
paddingVertical: 10,
backgroundColor: colors.white,
borderRadius: 6,
margin: 2,
marginBottom: 0,
},
loadMoreText: {
fontSize: 17,
color: colors.blue3,
},
}) })

View File

@ -2,6 +2,7 @@ import React, {useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, Text, View} from 'react-native' import {StyleSheet, Text, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import Svg, {Circle} from 'react-native-svg'
import {AtUri} from '../../../third-party/uri' import {AtUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -207,6 +208,22 @@ export const FeedItem = observer(function FeedItem({
</View> </View>
</View> </View>
</Link> </Link>
{item._isThreadChildElided ? (
<Link
style={styles.viewFullThread}
href={itemHref}
title={itemTitle}
noFeedback>
<View style={styles.viewFullThreadDots}>
<Svg width="4" height="30">
<Circle x="2" y="5" r="1.5" fill={colors.gray3} />
<Circle x="2" y="11" r="1.5" fill={colors.gray3} />
<Circle x="2" y="17" r="1.5" fill={colors.gray3} />
</Svg>
</View>
<Text style={styles.viewFullThreadText}>View full thread</Text>
</Link>
) : undefined}
</> </>
) )
}) })
@ -281,4 +298,18 @@ const styles = StyleSheet.create({
postEmbeds: { postEmbeds: {
marginBottom: 10, marginBottom: 10,
}, },
viewFullThread: {
backgroundColor: colors.white,
paddingTop: 4,
paddingLeft: 72,
},
viewFullThreadDots: {
position: 'absolute',
left: 35,
top: 0,
},
viewFullThreadText: {
color: colors.blue3,
fontSize: 16,
},
}) })