Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.tunjid.heron.data.core.models.FeedPreference.Companion.shouldHideRepl
import com.tunjid.heron.data.core.models.FeedPreference.Companion.shouldHideReposts
import com.tunjid.heron.data.core.models.Post
import com.tunjid.heron.data.core.models.Preferences
import com.tunjid.heron.data.core.models.ReplyNode
import com.tunjid.heron.data.core.models.Timeline
import com.tunjid.heron.data.core.models.TimelineItem
import com.tunjid.heron.data.core.models.id
Expand Down Expand Up @@ -535,7 +536,7 @@ internal class OfflineTimelineRepository(
associatedProfileIds = {
emptyList()
},
block = ::spinThread,
block = ::spinReplyTree,
)
}
.withRefresh {
Expand Down Expand Up @@ -1227,6 +1228,102 @@ internal class OfflineTimelineRepository(
else -> Unit
}
}

private fun spinReplyTree(
context: RecordResolver.TimelineItemCreationContext,
thread: ThreadedPostEntity,
) = with(context) {
val lastItem = list.lastOrNull()
when {
// Anchor post — start a ReplyTree with an empty reply list.
// Descendants (generation > 0) will be inserted as they arrive.
thread.generation == 0L -> list += TimelineItem.ReplyTree(
id = thread.entity.uri.uri,
post = post,
isMuted = isMuted(post),
threadGate = threadGate(post.uri),
appliedLabels = appliedLabels,
signedInProfileId = signedInProfileId,
replies = emptyList(),
)

// Ancestor posts — most distant arrives first per SQL ordering.
// First ancestor starts a Thread; subsequent ancestors append to it.
thread.generation < 0L -> when (lastItem) {
is TimelineItem.Thread -> list[list.lastIndex] = lastItem.copy(
posts = lastItem.posts + post,
isMuted = lastItem.isMuted || isMuted(post),
postUrisToThreadGates = lastItem.postUrisToThreadGates + (
post.uri to threadGate(post.uri)
),
)
else -> list += TimelineItem.Thread(
id = thread.entity.uri.uri,
generation = thread.generation,
isMuted = isMuted(post),
anchorPostIndex = 0,
hasBreak = false,
posts = listOf(post),
appliedLabels = appliedLabels,
signedInProfileId = signedInProfileId,
postUrisToThreadGates = mapOf(post.uri to threadGate(post.uri)),
)
}

// Descendant posts — insert into the ReplyTree using parentPostUri.
// SQL ordering guarantees a parent is always inserted before its children.
thread.generation > 0L -> {
// ReplyTree must exist from the anchor post — skip if data is malformed.
val replyTree = list.lastOrNull() as? TimelineItem.ReplyTree ?: return@with
// parentPostUri must be present on every descendant — skip if malformed.
val parentUri = thread.parentPostUri ?: return@with

val newNode = ReplyNode(
post = post,
threadGate = threadGate(post.uri),
appliedLabels = appliedLabels,
depth = thread.generation.toInt(),
children = emptyList(),
)

list[list.lastIndex] = replyTree.copy(
isMuted = replyTree.isMuted || isMuted(post),
replies = when (parentUri) {
replyTree.post.uri -> replyTree.replies + newNode
else -> replyTree.replies.withInsertedNode(
node = newNode,
parentPostUri = parentUri,
)
},
)
}
}
}

// Recursively finds the node matching parentPostUri and appends newNode as its child.
// Returns the list unchanged if the parent is not found, safe fallback for malformed data.
private fun List<ReplyNode>.withInsertedNode(
node: ReplyNode,
parentPostUri: PostUri,
): List<ReplyNode> {
for ((index, existingNode) in withIndex()) {
if (existingNode.post.uri == parentPostUri) {
val updatedNode = existingNode.copy(children = existingNode.children + node)
return toMutableList().apply { set(index, updatedNode) }
}
if (existingNode.children.isNotEmpty()) {
val newChildren = existingNode.children.withInsertedNode(
node = node,
parentPostUri = parentPostUri,
)
if (newChildren !== existingNode.children) {
val updatedNode = existingNode.copy(children = newChildren)
return toMutableList().apply { set(index, updatedNode) }
}
}
}
return this
}
}

private fun SavedStateDataSource.timelineFeedPreference(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ sealed class TimelineItem {
is Thread,
is Single,
is Placeholder,
is ReplyTree,
-> post.indexedAt

is Repost -> at
Expand Down Expand Up @@ -115,6 +116,15 @@ sealed class TimelineItem {
override val signedInProfileId: ProfileId?,
) : TimelineItem()

data class ReplyTree(
override val id: String,
override val post: Post,
override val isMuted: Boolean,
override val threadGate: ThreadGate?,
override val appliedLabels: AppliedLabels,
override val signedInProfileId: ProfileId?,
val replies: List<ReplyNode>,
) : TimelineItem()
sealed class Placeholder : TimelineItem() {
override val post: Post
get() = LoadingPost
Expand Down Expand Up @@ -168,3 +178,11 @@ sealed class TimelineItem {
val LoadingItems = (0..16).map { Loading() }
}
}

data class ReplyNode(
val post: Post,
val threadGate: ThreadGate?,
val appliedLabels: AppliedLabels,
val depth: Int,
val children: List<ReplyNode>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ fun postThreadsMutations(
it.uri.recordKey == route.postRecordKey
}
is TimelineItem.Placeholder -> null
// Anchor post now lives here, the ReplyTree's post is the anchor
is TimelineItem.ReplyTree -> item.post.takeIf {
it.uri.recordKey == route.postRecordKey
}
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ private fun TiledList<TimelineQuery, TimelineItem>.filterThreadDuplicates(): Til

is TimelineItem.Single -> !threadRootIds.contains(item.post.cid)
is TimelineItem.Placeholder -> false
is TimelineItem.ReplyTree -> !threadRootIds.contains(item.post.cid)
.also { contains ->
if (!contains) threadRootIds.add(item.post.cid)
}
}
}
.distinctBy(TimelineItem::id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ internal fun PostReasonLine(
},
)

is TimelineItem.ReplyTree,
is TimelineItem.Thread,
is TimelineItem.Single,
is TimelineItem.Placeholder,
Expand Down