A cheap attempt at a native Bluesky client for Android

ThreadView: Show nested replies

Visually indent replies in a thread to show their nesting level. This change restructures how threads are processed and displayed, showing the full conversation hierarchy instead of just a flat list of replies.

- Add a `nestingLevel` property to `SkeetData` to control indentation.
- Update `TimelineViewModel` to recursively process the entire thread structure.
- Modify `ThreadView` to render each branch of the conversation.

+59 -44
+3 -1
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 52 52 items = data.filter { !it.replyToNotFollowing }, 53 53 key = { it.key() } 54 54 ) { skeet -> 55 - Card { 55 + Card( 56 + modifier = Modifier.padding(start = (skeet.nestingLevel * 16).dp) 57 + ) { 56 58 val isRepost = when (skeet.reason) { 57 59 is FeedViewPostReasonUnion.ReasonRepost -> true 58 60 else -> false
+6 -2
app/src/main/java/industries/geesawra/monarch/ThreadView.kt
··· 4 4 import androidx.compose.foundation.layout.fillMaxSize 5 5 import androidx.compose.foundation.layout.padding 6 6 import androidx.compose.material3.ExperimentalMaterial3Api 7 + import androidx.compose.material3.HorizontalDivider 7 8 import androidx.compose.material3.MaterialTheme 8 9 import androidx.compose.material3.Scaffold 9 10 import androidx.compose.material3.Text ··· 18 19 import androidx.compose.runtime.remember 19 20 import androidx.compose.ui.Modifier 20 21 import androidx.compose.ui.input.nestedscroll.nestedScroll 22 + import androidx.compose.ui.util.fastForEach 21 23 import industries.geesawra.monarch.datalayer.TimelineViewModel 22 24 import kotlinx.coroutines.CoroutineScope 23 25 ··· 68 70 } 69 71 } 70 72 71 - if (timelineViewModel.uiState.currentlyShownThread.size != 1) { 73 + timelineViewModel.uiState.currentlyShownThread.fastForEach { threadView -> 72 74 ShowSkeets( 73 75 modifier = Modifier.padding(padding), 74 76 viewModel = timelineViewModel, 75 77 isScrollEnabled = true, 76 - data = timelineViewModel.uiState.currentlyShownThread, 78 + data = threadView, 77 79 shouldFetchMoreData = false, 78 80 isShowingThread = true, 79 81 ) 82 + 83 + HorizontalDivider() 80 84 } 81 85 } 82 86 }
+1
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 80 80 } 81 81 82 82 data class SkeetData( 83 + val nestingLevel: Int = 0, 83 84 val likes: Long? = null, 84 85 val reposts: Long? = null, 85 86 val replies: Long? = null,
+49 -41
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 56 56 57 57 val cidInteractedWith: Map<Cid, RKey> = mapOf(), 58 58 59 - val currentlyShownThread: List<SkeetData> = listOf(), 59 + val currentlyShownThread: List<List<SkeetData>> = listOf(), 60 60 61 61 val loginError: String? = null, 62 62 val error: String? = null, ··· 503 503 } 504 504 505 505 fun setThread(tappedElement: SkeetData) { 506 - uiState = uiState.copy(currentlyShownThread = listOf(tappedElement)) 506 + uiState = uiState.copy(currentlyShownThread = listOf(listOf(tappedElement))) 507 + } 508 + 509 + private fun getAllThreads(thread: GetPostThreadResponseThreadUnion): List<List<SkeetData>> { 510 + if (thread !is GetPostThreadResponseThreadUnion.ThreadViewPost) { 511 + return when (thread) { 512 + is GetPostThreadResponseThreadUnion.BlockedPost -> listOf(listOf(SkeetData(blocked = true))) 513 + is GetPostThreadResponseThreadUnion.NotFoundPost -> listOf(listOf(SkeetData(notFound = true))) 514 + else -> emptyList() 515 + } 516 + } 517 + 518 + val rootSkeet = SkeetData.fromPostView(thread.value.post, thread.value.post.author) 519 + val threads = mutableListOf<List<SkeetData>>() 520 + 521 + fun findPaths(current: ThreadViewPostReplieUnion, currentPath: List<SkeetData>, level: Int) { 522 + val skeet = when (current) { 523 + is ThreadViewPostReplieUnion.ThreadViewPost -> SkeetData.fromPostView( 524 + current.value.post, 525 + current.value.post.author 526 + ).copy(nestingLevel = level) 527 + 528 + is ThreadViewPostReplieUnion.BlockedPost -> SkeetData(blocked = true) 529 + is ThreadViewPostReplieUnion.NotFoundPost -> SkeetData(notFound = true) 530 + else -> null 531 + } 532 + 533 + if (skeet == null) return 534 + 535 + val newPath = currentPath + skeet 536 + val replies = (current as? ThreadViewPostReplieUnion.ThreadViewPost)?.value?.replies 537 + 538 + if (replies.isNullOrEmpty()) { 539 + threads.add(newPath) 540 + } else { 541 + replies.forEach { findPaths(it, newPath, level + 1) } 542 + } 543 + } 544 + 545 + if (thread.value.replies.isEmpty()) { 546 + threads.add(listOf(rootSkeet)) 547 + } else { 548 + thread.value.replies.forEach { findPaths(it, listOf(rootSkeet), 1) } 549 + } 550 + 551 + return threads.map { it.sortedBy { skeet -> skeet.createdAt } } 507 552 } 508 553 509 554 fun getThread(then: () -> Unit) { 510 555 viewModelScope.launch { 511 - bskyConn.getThread(uiState.currentlyShownThread.first().uri).onFailure { 556 + bskyConn.getThread(uiState.currentlyShownThread.first().first().uri).onFailure { 512 557 uiState = when (it) { 513 558 is LoginException -> uiState.copy(loginError = it.message) 514 559 else -> uiState.copy(error = it.message) 515 560 } 516 561 }.onSuccess { 517 562 uiState = uiState.copy( 518 - currentlyShownThread = uiState.currentlyShownThread + run { 519 - when (it.thread) { 520 - is GetPostThreadResponseThreadUnion.BlockedPost -> listOf( 521 - SkeetData( 522 - blocked = true 523 - ) 524 - ) 525 - 526 - is GetPostThreadResponseThreadUnion.NotFoundPost -> listOf( 527 - SkeetData( 528 - notFound = true 529 - ) 530 - ) 531 - 532 - is GetPostThreadResponseThreadUnion.ThreadViewPost -> (it.thread as GetPostThreadResponseThreadUnion.ThreadViewPost).value.replies.mapNotNull { r -> 533 - when (r) { 534 - is ThreadViewPostReplieUnion.BlockedPost -> SkeetData( 535 - blocked = true 536 - ) 537 - 538 - is ThreadViewPostReplieUnion.NotFoundPost -> SkeetData( 539 - notFound = true 540 - ) 541 - 542 - is ThreadViewPostReplieUnion.ThreadViewPost -> SkeetData.fromPostView( 543 - r.value.post, 544 - r.value.post.author 545 - ) 546 - 547 - is ThreadViewPostReplieUnion.Unknown -> null 548 - } 549 - } 550 - 551 - is GetPostThreadResponseThreadUnion.Unknown -> listOf() 552 - } 553 - } 563 + currentlyShownThread = getAllThreads(it.thread) 554 564 ) 555 - 556 565 then() 557 566 } 558 - 559 567 } 560 568 } 561 569 }