An ATproto social media client -- with an independent Appview.
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve thread rendering

+193 -100
+74 -39
src/state/models/feed-view.ts
··· 17 17 type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem 18 18 type FeedItemWithThreadMeta = FeedItem & { 19 19 _isThreadParent?: boolean 20 + _isThreadChildElided?: boolean 20 21 _isThreadChild?: boolean 21 22 } 22 23 ··· 34 35 // ui state 35 36 _reactKey: string = '' 36 37 _isThreadParent: boolean = false 38 + _isThreadChildElided: boolean = false 37 39 _isThreadChild: boolean = false 38 40 39 41 // data ··· 70 72 this.copy(v) 71 73 this._isThreadParent = v._isThreadParent || false 72 74 this._isThreadChild = v._isThreadChild || false 75 + this._isThreadChildElided = v._isThreadChildElided || false 73 76 } 74 77 75 78 copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) { ··· 469 472 this.loadMoreCursor = res.data.cursor 470 473 this.hasMore = !!this.loadMoreCursor 471 474 472 - // HACK 1 473 - // rearrange the posts to represent threads 474 - // (should be done on the server) 475 - // -prf 476 - // HACK 2 477 - // deduplicate posts on the home feed 478 - // (should be done on the server) 479 - // -prf 480 - const reorgedFeed = preprocessFeed(res.data.feed, this.feedType === 'home') 475 + const reorgedFeed = preprocessFeed(res.data.feed) 481 476 482 477 const promises = [] 483 478 const toAppend: FeedItemModel[] = [] ··· 569 564 } 570 565 } 571 566 572 - function preprocessFeed( 573 - feed: FeedItem[], 574 - dedup: boolean, 575 - ): FeedItemWithThreadMeta[] { 576 - // DEBUG 577 - // this has been temporarily disabled to see if it's the cause of some bugs 578 - // if the issues go away, we know this was the cause 579 - // -prf 580 - return feed 581 - // const reorg: FeedItemWithThreadMeta[] = [] 582 - // for (let i = feed.length - 1; i >= 0; i--) { 583 - // const item = feed[i] as FeedItemWithThreadMeta 567 + interface Slice { 568 + index: number 569 + length: number 570 + } 571 + function preprocessFeed(feed: FeedItem[]): FeedItemWithThreadMeta[] { 572 + const reorg: FeedItemWithThreadMeta[] = [] 584 573 585 - // if (dedup) { 586 - // if (reorg.find(item2 => item2.uri === item.uri)) { 587 - // continue 588 - // } 589 - // } 574 + // phase one: identify threads and reorganize them into the feed so 575 + // that they are in order and marked as part of a thread 576 + for (let i = feed.length - 1; i >= 0; i--) { 577 + const item = feed[i] as FeedItemWithThreadMeta 590 578 591 - // const selfReplyUri = getSelfReplyUri(item) 592 - // if (selfReplyUri) { 593 - // const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri) 594 - // if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) { 595 - // reorg[parentIndex]._isThreadParent = true 596 - // item._isThreadChild = true 597 - // reorg.splice(parentIndex + 1, 0, item) 598 - // continue 599 - // } 600 - // } 601 - // reorg.unshift(item) 602 - // } 603 - // return reorg 579 + const selfReplyUri = getSelfReplyUri(item) 580 + if (selfReplyUri) { 581 + const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri) 582 + if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) { 583 + reorg[parentIndex]._isThreadParent = true 584 + item._isThreadChild = true 585 + reorg.splice(parentIndex + 1, 0, item) 586 + continue 587 + } 588 + } 589 + reorg.unshift(item) 590 + } 591 + 592 + // phase two: identify the positions of the threads 593 + let activeSlice = -1 594 + let threadSlices: Slice[] = [] 595 + for (let i = 0; i < reorg.length; i++) { 596 + const item = reorg[i] as FeedItemWithThreadMeta 597 + if (activeSlice === -1) { 598 + if (item._isThreadParent) { 599 + activeSlice = i 600 + } 601 + } else { 602 + if (!item._isThreadChild) { 603 + threadSlices.push({index: activeSlice, length: i - activeSlice}) 604 + activeSlice = -1 605 + } 606 + } 607 + } 608 + if (activeSlice !== -1) { 609 + threadSlices.push({index: activeSlice, length: reorg.length - activeSlice}) 610 + } 611 + 612 + // phase three: reorder the feed so that the timestamp of the 613 + // last post in a thread establishes its ordering 614 + for (const slice of threadSlices) { 615 + const removed: FeedItemWithThreadMeta[] = reorg.splice( 616 + slice.index, 617 + slice.length, 618 + ) 619 + const targetDate = new Date(removed[removed.length - 1].indexedAt) 620 + const newIndex = reorg.findIndex( 621 + item => new Date(item.indexedAt) < targetDate, 622 + ) 623 + reorg.splice(newIndex, 0, ...removed) 624 + slice.index = newIndex 625 + } 626 + 627 + // phase four: compress any threads that are longer than 3 posts 628 + let removedCount = 0 629 + for (const slice of threadSlices) { 630 + if (slice.length > 3) { 631 + reorg.splice(slice.index - removedCount + 1, slice.length - 3) 632 + reorg[slice.index - removedCount]._isThreadChildElided = true 633 + console.log(reorg[slice.index - removedCount]) 634 + removedCount += slice.length - 3 635 + } 636 + } 637 + 638 + return reorg 604 639 } 605 640 606 641 function getSelfReplyUri(
+1
src/state/models/post-thread-view.ts
··· 48 48 _reactKey: string = '' 49 49 _depth = 0 50 50 _isHighlightedPost = false 51 + _hasMore = false 51 52 52 53 // data 53 54 $type: string = ''
+4 -1
src/view/com/post-thread/PostThread.tsx
··· 90 90 91 91 function* flattenThread( 92 92 post: PostThreadViewPostModel, 93 + isAscending = false, 93 94 ): Generator<PostThreadViewPostModel, void> { 94 95 if (post.parent) { 95 - yield* flattenThread(post.parent) 96 + yield* flattenThread(post.parent, true) 96 97 } 97 98 yield post 98 99 if (post.replies?.length) { 99 100 for (const reply of post.replies) { 100 101 yield* flattenThread(reply) 101 102 } 103 + } else if (!isAscending && !post.parent && post.replyCount > 0) { 104 + post._hasMore = true 102 105 } 103 106 }
+83 -60
src/view/com/post-thread/PostThreadItem.tsx
··· 226 226 ) 227 227 } else { 228 228 return ( 229 - <Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback> 230 - {!item.replyingTo && item.record.reply && ( 231 - <View style={styles.parentReplyLine} /> 232 - )} 233 - {item.replies?.length !== 0 && <View style={styles.childReplyLine} />} 234 - {item.replyingTo ? ( 235 - <View style={styles.replyingTo}> 236 - <View style={styles.replyingToLine} /> 237 - <View style={styles.replyingToAvatar}> 238 - <UserAvatar 239 - handle={item.replyingTo.author.handle} 240 - displayName={item.replyingTo.author.displayName} 241 - avatar={item.replyingTo.author.avatar} 242 - size={30} 243 - /> 229 + <> 230 + <Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback> 231 + {!item.replyingTo && item.record.reply && ( 232 + <View style={styles.parentReplyLine} /> 233 + )} 234 + {item.replies?.length !== 0 && <View style={styles.childReplyLine} />} 235 + {item.replyingTo ? ( 236 + <View style={styles.replyingTo}> 237 + <View style={styles.replyingToLine} /> 238 + <View style={styles.replyingToAvatar}> 239 + <UserAvatar 240 + handle={item.replyingTo.author.handle} 241 + displayName={item.replyingTo.author.displayName} 242 + avatar={item.replyingTo.author.avatar} 243 + size={30} 244 + /> 245 + </View> 246 + <Text style={styles.replyingToText} numberOfLines={2}> 247 + {item.replyingTo.text} 248 + </Text> 244 249 </View> 245 - <Text style={styles.replyingToText} numberOfLines={2}> 246 - {item.replyingTo.text} 247 - </Text> 248 - </View> 249 - ) : undefined} 250 - <View style={styles.layout}> 251 - <View style={styles.layoutAvi}> 252 - <Link href={authorHref} title={authorTitle}> 253 - <UserAvatar 254 - size={50} 255 - displayName={item.author.displayName} 256 - handle={item.author.handle} 257 - avatar={item.author.avatar} 250 + ) : undefined} 251 + <View style={styles.layout}> 252 + <View style={styles.layoutAvi}> 253 + <Link href={authorHref} title={authorTitle}> 254 + <UserAvatar 255 + size={50} 256 + displayName={item.author.displayName} 257 + handle={item.author.handle} 258 + avatar={item.author.avatar} 259 + /> 260 + </Link> 261 + </View> 262 + <View style={styles.layoutContent}> 263 + <PostMeta 264 + itemHref={itemHref} 265 + itemTitle={itemTitle} 266 + authorHref={authorHref} 267 + authorHandle={item.author.handle} 268 + authorDisplayName={item.author.displayName} 269 + timestamp={item.indexedAt} 270 + isAuthor={item.author.did === store.me.did} 271 + onCopyPostText={onCopyPostText} 272 + onDeletePost={onDeletePost} 258 273 /> 259 - </Link> 260 - </View> 261 - <View style={styles.layoutContent}> 262 - <PostMeta 263 - itemHref={itemHref} 264 - itemTitle={itemTitle} 265 - authorHref={authorHref} 266 - authorHandle={item.author.handle} 267 - authorDisplayName={item.author.displayName} 268 - timestamp={item.indexedAt} 269 - isAuthor={item.author.did === store.me.did} 270 - onCopyPostText={onCopyPostText} 271 - onDeletePost={onDeletePost} 272 - /> 273 - <View style={styles.postTextContainer}> 274 - <RichText 275 - text={record.text} 276 - entities={record.entities} 277 - style={[styles.postText]} 274 + <View style={styles.postTextContainer}> 275 + <RichText 276 + text={record.text} 277 + entities={record.entities} 278 + style={[styles.postText]} 279 + /> 280 + </View> 281 + <PostEmbeds embed={item.embed} style={{marginBottom: 10}} /> 282 + <PostCtrls 283 + replyCount={item.replyCount} 284 + repostCount={item.repostCount} 285 + upvoteCount={item.upvoteCount} 286 + isReposted={!!item.myState.repost} 287 + isUpvoted={!!item.myState.upvote} 288 + onPressReply={onPressReply} 289 + onPressToggleRepost={onPressToggleRepost} 290 + onPressToggleUpvote={onPressToggleUpvote} 278 291 /> 279 292 </View> 280 - <PostEmbeds embed={item.embed} style={{marginBottom: 10}} /> 281 - <PostCtrls 282 - replyCount={item.replyCount} 283 - repostCount={item.repostCount} 284 - upvoteCount={item.upvoteCount} 285 - isReposted={!!item.myState.repost} 286 - isUpvoted={!!item.myState.upvote} 287 - onPressReply={onPressReply} 288 - onPressToggleRepost={onPressToggleRepost} 289 - onPressToggleUpvote={onPressToggleUpvote} 290 - /> 291 293 </View> 292 - </View> 293 - </Link> 294 + </Link> 295 + {item._hasMore ? ( 296 + <Link 297 + style={styles.loadMore} 298 + href={itemHref} 299 + title={itemTitle} 300 + noFeedback> 301 + <Text style={styles.loadMoreText}>Load more</Text> 302 + </Link> 303 + ) : undefined} 304 + </> 294 305 ) 295 306 } 296 307 }) ··· 397 408 }, 398 409 expandedInfoItem: { 399 410 marginRight: 10, 411 + }, 412 + loadMore: { 413 + paddingLeft: 28, 414 + paddingVertical: 10, 415 + backgroundColor: colors.white, 416 + borderRadius: 6, 417 + margin: 2, 418 + marginBottom: 0, 419 + }, 420 + loadMoreText: { 421 + fontSize: 17, 422 + color: colors.blue3, 400 423 }, 401 424 })
+31
src/view/com/posts/FeedItem.tsx
··· 2 2 import {observer} from 'mobx-react-lite' 3 3 import {StyleSheet, Text, View} from 'react-native' 4 4 import Clipboard from '@react-native-clipboard/clipboard' 5 + import Svg, {Circle} from 'react-native-svg' 5 6 import {AtUri} from '../../../third-party/uri' 6 7 import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' 7 8 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 207 208 </View> 208 209 </View> 209 210 </Link> 211 + {item._isThreadChildElided ? ( 212 + <Link 213 + style={styles.viewFullThread} 214 + href={itemHref} 215 + title={itemTitle} 216 + noFeedback> 217 + <View style={styles.viewFullThreadDots}> 218 + <Svg width="4" height="30"> 219 + <Circle x="2" y="5" r="1.5" fill={colors.gray3} /> 220 + <Circle x="2" y="11" r="1.5" fill={colors.gray3} /> 221 + <Circle x="2" y="17" r="1.5" fill={colors.gray3} /> 222 + </Svg> 223 + </View> 224 + <Text style={styles.viewFullThreadText}>View full thread</Text> 225 + </Link> 226 + ) : undefined} 210 227 </> 211 228 ) 212 229 }) ··· 280 297 }, 281 298 postEmbeds: { 282 299 marginBottom: 10, 300 + }, 301 + viewFullThread: { 302 + backgroundColor: colors.white, 303 + paddingTop: 4, 304 + paddingLeft: 72, 305 + }, 306 + viewFullThreadDots: { 307 + position: 'absolute', 308 + left: 35, 309 + top: 0, 310 + }, 311 + viewFullThreadText: { 312 + color: colors.blue3, 313 + fontSize: 16, 283 314 }, 284 315 })