about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2022-12-18 18:54:05 -0600
committerPaul Frazee <pfrazee@gmail.com>2022-12-18 18:54:05 -0600
commitae3099dfca13f6651762f6ea9a3d2a14ebc99df4 (patch)
treec78136d8fb2f15da0c1be3bef8d0e732287d050f
parent69b86255c6c2275b3403ce6654b17e0a2c56ced6 (diff)
downloadvoidsky-ae3099dfca13f6651762f6ea9a3d2a14ebc99df4.tar.zst
Improve thread rendering
-rw-r--r--src/state/models/feed-view.ts117
-rw-r--r--src/state/models/post-thread-view.ts1
-rw-r--r--src/view/com/post-thread/PostThread.tsx5
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx143
-rw-r--r--src/view/com/posts/FeedItem.tsx31
5 files changed, 195 insertions, 102 deletions
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index f2832887a..f8080d4b5 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -17,6 +17,7 @@ let _idCounter = 0
 type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
 type FeedItemWithThreadMeta = FeedItem & {
   _isThreadParent?: boolean
+  _isThreadChildElided?: boolean
   _isThreadChild?: boolean
 }
 
@@ -34,6 +35,7 @@ export class FeedItemModel implements GetTimeline.FeedItem {
   // ui state
   _reactKey: string = ''
   _isThreadParent: boolean = false
+  _isThreadChildElided: boolean = false
   _isThreadChild: boolean = false
 
   // data
@@ -70,6 +72,7 @@ export class FeedItemModel implements GetTimeline.FeedItem {
     this.copy(v)
     this._isThreadParent = v._isThreadParent || false
     this._isThreadChild = v._isThreadChild || false
+    this._isThreadChildElided = v._isThreadChildElided || false
   }
 
   copy(v: GetTimeline.FeedItem | GetAuthorFeed.FeedItem) {
@@ -469,15 +472,7 @@ export class FeedModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
 
-    // HACK 1
-    // 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 reorgedFeed = preprocessFeed(res.data.feed)
 
     const promises = []
     const toAppend: FeedItemModel[] = []
@@ -569,38 +564,78 @@ export class FeedModel {
   }
 }
 
-function preprocessFeed(
-  feed: FeedItem[],
-  dedup: boolean,
-): FeedItemWithThreadMeta[] {
-  // DEBUG
-  // this has been temporarily disabled to see if it's the cause of some bugs
-  // 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) {
-  //     if (reorg.find(item2 => item2.uri === item.uri)) {
-  //       continue
-  //     }
-  //   }
-
-  //   const selfReplyUri = getSelfReplyUri(item)
-  //   if (selfReplyUri) {
-  //     const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
-  //     if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
-  //       reorg[parentIndex]._isThreadParent = true
-  //       item._isThreadChild = true
-  //       reorg.splice(parentIndex + 1, 0, item)
-  //       continue
-  //     }
-  //   }
-  //   reorg.unshift(item)
-  // }
-  // return reorg
+interface Slice {
+  index: number
+  length: number
+}
+function preprocessFeed(feed: FeedItem[]): FeedItemWithThreadMeta[] {
+  const reorg: FeedItemWithThreadMeta[] = []
+
+  // phase one: identify threads and reorganize them into the feed so
+  // that they are in order and marked as part of a thread
+  for (let i = feed.length - 1; i >= 0; i--) {
+    const item = feed[i] as FeedItemWithThreadMeta
+
+    const selfReplyUri = getSelfReplyUri(item)
+    if (selfReplyUri) {
+      const parentIndex = reorg.findIndex(item2 => item2.uri === selfReplyUri)
+      if (parentIndex !== -1 && !reorg[parentIndex]._isThreadParent) {
+        reorg[parentIndex]._isThreadParent = true
+        item._isThreadChild = true
+        reorg.splice(parentIndex + 1, 0, item)
+        continue
+      }
+    }
+    reorg.unshift(item)
+  }
+
+  // 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(
diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts
index ebe5b730d..64de7d260 100644
--- a/src/state/models/post-thread-view.ts
+++ b/src/state/models/post-thread-view.ts
@@ -48,6 +48,7 @@ export class PostThreadViewPostModel implements GetPostThread.Post {
   _reactKey: string = ''
   _depth = 0
   _isHighlightedPost = false
+  _hasMore = false
 
   // data
   $type: string = ''
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index bbaa4efa2..0df505a74 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -90,14 +90,17 @@ export const PostThread = observer(function PostThread({
 
 function* flattenThread(
   post: PostThreadViewPostModel,
+  isAscending = false,
 ): Generator<PostThreadViewPostModel, void> {
   if (post.parent) {
-    yield* flattenThread(post.parent)
+    yield* flattenThread(post.parent, true)
   }
   yield post
   if (post.replies?.length) {
     for (const reply of post.replies) {
       yield* flattenThread(reply)
     }
+  } else if (!isAscending && !post.parent && post.replyCount > 0) {
+    post._hasMore = true
   }
 }
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 1a0c744d6..45fd86116 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -226,71 +226,82 @@ export const PostThreadItem = observer(function PostThreadItem({
     )
   } else {
     return (
-      <Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback>
-        {!item.replyingTo && item.record.reply && (
-          <View style={styles.parentReplyLine} />
-        )}
-        {item.replies?.length !== 0 && <View style={styles.childReplyLine} />}
-        {item.replyingTo ? (
-          <View style={styles.replyingTo}>
-            <View style={styles.replyingToLine} />
-            <View style={styles.replyingToAvatar}>
-              <UserAvatar
-                handle={item.replyingTo.author.handle}
-                displayName={item.replyingTo.author.displayName}
-                avatar={item.replyingTo.author.avatar}
-                size={30}
-              />
+      <>
+        <Link style={styles.outer} href={itemHref} title={itemTitle} noFeedback>
+          {!item.replyingTo && item.record.reply && (
+            <View style={styles.parentReplyLine} />
+          )}
+          {item.replies?.length !== 0 && <View style={styles.childReplyLine} />}
+          {item.replyingTo ? (
+            <View style={styles.replyingTo}>
+              <View style={styles.replyingToLine} />
+              <View style={styles.replyingToAvatar}>
+                <UserAvatar
+                  handle={item.replyingTo.author.handle}
+                  displayName={item.replyingTo.author.displayName}
+                  avatar={item.replyingTo.author.avatar}
+                  size={30}
+                />
+              </View>
+              <Text style={styles.replyingToText} numberOfLines={2}>
+                {item.replyingTo.text}
+              </Text>
             </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}
+          ) : 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}
               />
-            </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 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>
-            <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>
+        </Link>
+        {item._hasMore ? (
+          <Link
+            style={styles.loadMore}
+            href={itemHref}
+            title={itemTitle}
+            noFeedback>
+            <Text style={styles.loadMoreText}>Load more</Text>
+          </Link>
+        ) : undefined}
+      </>
     )
   }
 })
@@ -398,4 +409,16 @@ const styles = StyleSheet.create({
   expandedInfoItem: {
     marginRight: 10,
   },
+  loadMore: {
+    paddingLeft: 28,
+    paddingVertical: 10,
+    backgroundColor: colors.white,
+    borderRadius: 6,
+    margin: 2,
+    marginBottom: 0,
+  },
+  loadMoreText: {
+    fontSize: 17,
+    color: colors.blue3,
+  },
 })
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 74edad365..51f76904f 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -2,6 +2,7 @@ import React, {useMemo, useState} from 'react'
 import {observer} from 'mobx-react-lite'
 import {StyleSheet, Text, View} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
+import Svg, {Circle} from 'react-native-svg'
 import {AtUri} from '../../../third-party/uri'
 import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -207,6 +208,22 @@ export const FeedItem = observer(function FeedItem({
           </View>
         </View>
       </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: {
     marginBottom: 10,
   },
+  viewFullThread: {
+    backgroundColor: colors.white,
+    paddingTop: 4,
+    paddingLeft: 72,
+  },
+  viewFullThreadDots: {
+    position: 'absolute',
+    left: 35,
+    top: 0,
+  },
+  viewFullThreadText: {
+    color: colors.blue3,
+    fontSize: 16,
+  },
 })