A cheap attempt at a native Bluesky client for Android

*: some resemblance of thread loading

+252 -45
+10 -3
app/src/main/java/industries/geesawra/monarch/ConditionalCard.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 + import androidx.compose.foundation.clickable 3 4 import androidx.compose.foundation.layout.Arrangement 4 5 import androidx.compose.foundation.layout.Column 5 6 import androidx.compose.foundation.layout.fillMaxSize ··· 14 15 import androidx.compose.ui.unit.dp 15 16 16 17 @Composable 17 - fun ConditionalCard(text: String, wrapWithCard: Boolean = true) { 18 + fun ConditionalCard( 19 + modifier: Modifier = Modifier, 20 + onTap: () -> Unit = {}, 21 + text: String, 22 + wrapWithCard: Boolean = true 23 + ) { 18 24 if (wrapWithCard) { 19 25 OutlinedCard( 20 - modifier = Modifier 26 + modifier = modifier 21 27 .height(80.dp) 22 28 .padding(8.dp) 23 29 .fillMaxWidth() ··· 25 31 Column( 26 32 modifier = Modifier 27 33 .fillMaxSize() 28 - .padding(start = 16.dp), 34 + .clickable { onTap() }, 29 35 verticalArrangement = Arrangement.Center 30 36 ) { 31 37 Text( 38 + modifier = Modifier.padding(start = 16.dp), 32 39 text = text, 33 40 color = MaterialTheme.colorScheme.onSurfaceVariant 34 41 )
+27 -9
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 5 5 import androidx.activity.ComponentActivity 6 6 import androidx.activity.compose.setContent 7 7 import androidx.activity.enableEdgeToEdge 8 - import androidx.compose.animation.EnterTransition 9 8 import androidx.compose.animation.ExperimentalSharedTransitionApi 10 - import androidx.compose.animation.scaleOut 9 + import androidx.compose.animation.slideInHorizontally 10 + import androidx.compose.animation.slideOutHorizontally 11 11 import androidx.compose.foundation.layout.Box 12 + import androidx.compose.foundation.layout.WindowInsets 12 13 import androidx.compose.foundation.layout.fillMaxSize 14 + import androidx.compose.foundation.layout.statusBars 15 + import androidx.compose.foundation.layout.windowInsetsPadding 13 16 import androidx.compose.foundation.rememberScrollState 14 17 import androidx.compose.foundation.verticalScroll 15 18 import androidx.compose.material3.ExperimentalMaterial3Api ··· 18 21 import androidx.compose.runtime.rememberCoroutineScope 19 22 import androidx.compose.ui.Alignment 20 23 import androidx.compose.ui.Modifier 21 - import androidx.compose.ui.graphics.TransformOrigin 22 24 import androidx.compose.ui.platform.LocalContext 23 25 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 24 26 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel ··· 44 46 enum class ViewList() { 45 47 Login, 46 48 Main, 49 + ShowThread, 47 50 } 48 51 49 52 @AndroidEntryPoint ··· 106 109 navController = navController, 107 110 startDestination = initialRoute, 108 111 modifier = Modifier.fillMaxSize(), 109 - popExitTransition = { 110 - scaleOut( 111 - targetScale = 0.9f, 112 - transformOrigin = TransformOrigin(0.5f, 0.5f) 113 - ) 112 + enterTransition = { 113 + slideInHorizontally(initialOffsetX = { it }) 114 + }, 115 + exitTransition = { 116 + slideOutHorizontally(targetOffsetX = { -it }) 114 117 }, 115 118 popEnterTransition = { 116 - EnterTransition.None 119 + slideInHorizontally(initialOffsetX = { -it }) 120 + }, 121 + popExitTransition = { 122 + slideOutHorizontally(targetOffsetX = { it }) 117 123 }, 118 124 ) { 119 125 composable(route = ViewList.Main.name) { ··· 122 128 coroutineScope = rememberCoroutineScope(), 123 129 onLoginError = { 124 130 navController.navigate(ViewList.Login.name) 131 + }, 132 + onThreadTap = { 133 + navController.navigate(ViewList.ShowThread.name) 125 134 } 126 135 ) 127 136 } 137 + composable(route = ViewList.ShowThread.name) { 138 + ThreadView( 139 + modifier = Modifier 140 + .windowInsetsPadding(WindowInsets.statusBars), 141 + timelineViewModel = timelineViewModel, 142 + coroutineScope = rememberCoroutineScope(), 143 + ) 144 + } 145 + 128 146 composable(route = ViewList.Login.name) { 129 147 Surface( 130 148 modifier = Modifier.fillMaxSize(),
+8 -1
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 95 95 timelineViewModel: TimelineViewModel, 96 96 coroutineScope: CoroutineScope, 97 97 onLoginError: () -> Unit, 98 + onThreadTap: (SkeetData) -> Unit, 98 99 ) { 99 100 val scrollState = rememberScrollState() 100 101 val scaffoldState = rememberBottomSheetScaffoldState( ··· 153 154 withDismissAction = true, 154 155 ) 155 156 } 157 + }, 158 + onSeeMoreTap = { 159 + onThreadTap(it) 156 160 } 157 161 ) 158 162 } ··· 169 173 fobOnClick: () -> Unit, 170 174 loginError: () -> Unit, 171 175 onError: (String) -> Unit, 176 + onSeeMoreTap: (SkeetData) -> Unit, 172 177 ) { 173 178 var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.TIMELINE) } 174 179 val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( ··· 429 434 state = timelineState, 430 435 modifier = Modifier.padding(values), 431 436 onReplyTap = onReplyTap, 432 - isScrollEnabled = isScrollEnabled 437 + data = timelineViewModel.uiState.skeets, 438 + isScrollEnabled = isScrollEnabled, 439 + onSeeMoreTap = onSeeMoreTap 433 440 ) 434 441 435 442 TabBarDestinations.NOTIFICATIONS -> NotificationsView(
+2 -7
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 8 8 import androidx.compose.foundation.layout.width 9 9 import androidx.compose.foundation.lazy.LazyColumn 10 10 import androidx.compose.foundation.lazy.LazyListState 11 - import androidx.compose.material3.CardDefaults 11 + import androidx.compose.material3.Card 12 12 import androidx.compose.material3.CircularProgressIndicator 13 - import androidx.compose.material3.ElevatedCard 14 13 import androidx.compose.runtime.Composable 15 14 import androidx.compose.runtime.LaunchedEffect 16 15 import androidx.compose.runtime.derivedStateOf ··· 43 42 ) { 44 43 viewModel.uiState.notifications.forEach { notif -> 45 44 item(notif.createdAt()) { 46 - ElevatedCard( 47 - elevation = CardDefaults.elevatedCardElevation( 48 - defaultElevation = 0.dp 49 - ) 50 - ) { 45 + Card { 51 46 RenderNotification( 52 47 viewModel = viewModel, 53 48 notification = notif,
+32 -22
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 9 9 import androidx.compose.foundation.layout.width 10 10 import androidx.compose.foundation.lazy.LazyColumn 11 11 import androidx.compose.foundation.lazy.LazyListState 12 + import androidx.compose.foundation.lazy.items 12 13 import androidx.compose.foundation.lazy.rememberLazyListState 13 14 import androidx.compose.foundation.shape.RoundedCornerShape 14 - import androidx.compose.material3.CardDefaults 15 + import androidx.compose.material3.Card 15 16 import androidx.compose.material3.CircularProgressIndicator 16 - import androidx.compose.material3.ElevatedCard 17 17 import androidx.compose.material3.VerticalDivider 18 18 import androidx.compose.runtime.Composable 19 19 import androidx.compose.runtime.LaunchedEffect ··· 34 34 viewModel: TimelineViewModel, 35 35 isScrollEnabled: Boolean, 36 36 state: LazyListState = rememberLazyListState(), 37 + data: List<SkeetData>, 38 + isShowingThread: Boolean = false, 39 + shouldFetchMoreData: Boolean = true, 37 40 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 41 + onSeeMoreTap: ((SkeetData) -> Unit)? = null, 38 42 ) { 39 43 LazyColumn( 40 44 state = state, ··· 44 48 .padding(horizontal = 16.dp), 45 49 verticalArrangement = Arrangement.spacedBy(16.dp), 46 50 ) { 47 - viewModel.uiState.skeets.filter { 48 - !it.replyToNotFollowing 49 - }.forEach { skeet -> 50 - item(key = skeet.key()) { 51 - ElevatedCard( 52 - elevation = CardDefaults.elevatedCardElevation( 53 - defaultElevation = 0.dp 54 - ) 55 - ) { 56 - val isRepost = when (skeet.reason) { 57 - is FeedViewPostReasonUnion.ReasonRepost -> true 58 - else -> false 59 - } 51 + items( 52 + items = data.filter { !it.replyToNotFollowing }, 53 + key = { it.key() } 54 + ) { skeet -> 55 + Card { 56 + val isRepost = when (skeet.reason) { 57 + is FeedViewPostReasonUnion.ReasonRepost -> true 58 + else -> false 59 + } 60 60 61 - val root = skeet.root() 62 - val (parent, parentsParent) = skeet.parent() 61 + val root = skeet.root() 62 + val (parent, parentsParent) = skeet.parent() 63 63 64 + 65 + if (!isShowingThread) { 64 66 if (!isRepost) { 65 67 root?.let { 66 68 SkeetView( ··· 73 75 74 76 parent?.let { 75 77 if ((parentsParent?.cid != root?.cid) && root?.cid != null) { 76 - ConditionalCard("See more") 78 + ConditionalCard( 79 + text = "See more", 80 + onTap = { 81 + if (onSeeMoreTap != null) { 82 + viewModel.setThread(root) 83 + onSeeMoreTap(root) 84 + } 85 + } 86 + ) 77 87 78 88 VerticalDivider( 79 89 thickness = 4.dp, ··· 92 102 ) 93 103 } 94 104 } 105 + } 95 106 96 107 97 - SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 98 - } 108 + SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 99 109 } 100 110 } 101 111 102 - if (viewModel.uiState.isFetchingMoreTimeline && viewModel.uiState.skeets.isNotEmpty()) { 112 + if (viewModel.uiState.isFetchingMoreTimeline && data.isNotEmpty() && shouldFetchMoreData) { 103 113 item { 104 114 Box( 105 115 modifier = Modifier ··· 135 145 } 136 146 137 147 LaunchedEffect(endOfListReached) { 138 - if (endOfListReached && viewModel.uiState.skeets.isNotEmpty()) { 148 + if (endOfListReached && viewModel.uiState.skeets.isNotEmpty() && shouldFetchMoreData) { 139 149 viewModel.fetchTimeline() 140 150 } 141 151 }
+2 -2
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 71 71 inThread: Boolean = false, 72 72 ) { 73 73 if (skeet.blocked) { 74 - ConditionalCard("Blocked :(", wrapWithCard = !nested) 74 + ConditionalCard(text = "Blocked :(", wrapWithCard = !nested) 75 75 return 76 76 } 77 77 78 78 if (skeet.notFound) { 79 - ConditionalCard("Post not found", wrapWithCard = !nested) 79 + ConditionalCard(text = "Post not found", wrapWithCard = !nested) 80 80 return 81 81 } 82 82
+84
app/src/main/java/industries/geesawra/monarch/ThreadView.kt
··· 1 + package industries.geesawra.monarch 2 + 3 + import android.util.Log 4 + import androidx.compose.foundation.layout.fillMaxSize 5 + import androidx.compose.foundation.layout.padding 6 + import androidx.compose.material3.ExperimentalMaterial3Api 7 + import androidx.compose.material3.MaterialTheme 8 + import androidx.compose.material3.Scaffold 9 + import androidx.compose.material3.Text 10 + import androidx.compose.material3.TopAppBar 11 + import androidx.compose.material3.TopAppBarColors 12 + import androidx.compose.material3.TopAppBarDefaults 13 + import androidx.compose.material3.pulltorefresh.PullToRefreshBox 14 + import androidx.compose.material3.rememberTopAppBarState 15 + import androidx.compose.runtime.Composable 16 + import androidx.compose.runtime.LaunchedEffect 17 + import androidx.compose.runtime.mutableStateOf 18 + import androidx.compose.runtime.remember 19 + import androidx.compose.ui.Modifier 20 + import androidx.compose.ui.input.nestedscroll.nestedScroll 21 + import industries.geesawra.monarch.datalayer.TimelineViewModel 22 + import kotlinx.coroutines.CoroutineScope 23 + 24 + @OptIn(ExperimentalMaterial3Api::class) 25 + @Composable 26 + fun ThreadView( 27 + modifier: Modifier = Modifier, 28 + timelineViewModel: TimelineViewModel, 29 + coroutineScope: CoroutineScope 30 + ) { 31 + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( 32 + rememberTopAppBarState() 33 + ) 34 + 35 + val isRefreshing = remember { mutableStateOf(true) } 36 + 37 + PullToRefreshBox( 38 + modifier = modifier, 39 + isRefreshing = isRefreshing.value, 40 + onRefresh = {}, 41 + ) { 42 + Scaffold( 43 + containerColor = MaterialTheme.colorScheme.background, 44 + modifier = modifier 45 + .fillMaxSize() 46 + .nestedScroll(scrollBehavior.nestedScrollConnection), 47 + topBar = { 48 + TopAppBar( 49 + colors = TopAppBarColors( 50 + containerColor = MaterialTheme.colorScheme.background, 51 + scrolledContainerColor = MaterialTheme.colorScheme.background, 52 + navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast 53 + titleContentColor = MaterialTheme.colorScheme.onBackground, 54 + actionIconContentColor = MaterialTheme.colorScheme.onBackground, 55 + subtitleContentColor = MaterialTheme.colorScheme.onBackground 56 + ), 57 + title = { 58 + Text("Thread") 59 + }, 60 + scrollBehavior = scrollBehavior, 61 + ) 62 + }, 63 + ) { padding -> 64 + LaunchedEffect(Unit) { 65 + timelineViewModel.getThread { 66 + Log.d("ThreadView", "Thread retrieved") 67 + isRefreshing.value = false 68 + } 69 + } 70 + 71 + if (timelineViewModel.uiState.currentlyShownThread.size != 1) { 72 + ShowSkeets( 73 + modifier = Modifier.padding(padding), 74 + viewModel = timelineViewModel, 75 + isScrollEnabled = true, 76 + data = timelineViewModel.uiState.currentlyShownThread, 77 + shouldFetchMoreData = false, 78 + isShowingThread = true, 79 + ) 80 + } 81 + } 82 + } 83 + } 84 +
+21
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 26 26 import app.bsky.feed.GetFeedGeneratorsQueryParams 27 27 import app.bsky.feed.GetFeedQueryParams 28 28 import app.bsky.feed.GetFeedResponse 29 + import app.bsky.feed.GetPostThreadQueryParams 30 + import app.bsky.feed.GetPostThreadResponse 29 31 import app.bsky.feed.GetPostsQueryParams 30 32 import app.bsky.feed.GetPostsResponse 31 33 import app.bsky.feed.GetTimelineQueryParams ··· 982 984 983 985 suspend fun deleteRepost(rKey: RKey): Result<Unit> { 984 986 return deleteRecord(rKey, "app.bsky.feed.repost") 987 + } 988 + 989 + suspend fun getThread(uri: AtUri): Result<GetPostThreadResponse> { 990 + return runCatching { 991 + create().onFailure { 992 + return Result.failure(LoginException(it.message)) 993 + } 994 + 995 + val res = client!!.getPostThread( 996 + GetPostThreadQueryParams( 997 + uri = uri, 998 + ) 999 + ) 1000 + 1001 + return when (res) { 1002 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not get thread: ${res.error?.message}")) 1003 + is AtpResponse.Success<GetPostThreadResponse> -> Result.success(res.response) 1004 + } 1005 + } 985 1006 } 986 1007 987 1008 private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> {
+66 -1
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 12 12 import app.bsky.actor.ProfileView 13 13 import app.bsky.actor.ProfileViewDetailed 14 14 import app.bsky.feed.GeneratorView 15 + import app.bsky.feed.GetPostThreadResponseThreadUnion 15 16 import app.bsky.feed.Like 16 17 import app.bsky.feed.Post 17 18 import app.bsky.feed.PostEmbedUnion 18 19 import app.bsky.feed.PostReplyRef 19 20 import app.bsky.feed.Repost 21 + import app.bsky.feed.ThreadViewPostReplieUnion 20 22 import app.bsky.graph.Follow 21 23 import app.bsky.notification.ListNotificationsReason 22 24 import com.atproto.repo.StrongRef ··· 53 55 val notificationsCursor: String? = null, 54 56 55 57 val cidInteractedWith: Map<Cid, RKey> = mapOf(), 58 + 59 + val currentlyShownThread: List<SkeetData> = listOf(), 56 60 57 61 val loginError: String? = null, 58 62 val error: String? = null, ··· 195 199 isFetchingMoreNotifications = false, 196 200 error = "Failed to fetch notifications: ${it.message}" 197 201 ) 198 - }.getOrThrow() 202 + }.getOrNull() 203 + 204 + if (rawNotifs == null) { 205 + return@launch 206 + } 199 207 200 208 val repeatable = mutableListOf<Notification>() 201 209 ··· 491 499 ) 492 500 then() 493 501 } 502 + } 503 + } 504 + 505 + fun setThread(tappedElement: SkeetData) { 506 + uiState = uiState.copy(currentlyShownThread = listOf(tappedElement)) 507 + } 508 + 509 + fun getThread(then: () -> Unit) { 510 + viewModelScope.launch { 511 + bskyConn.getThread(uiState.currentlyShownThread.first().uri).onFailure { 512 + uiState = when (it) { 513 + is LoginException -> uiState.copy(loginError = it.message) 514 + else -> uiState.copy(error = it.message) 515 + } 516 + }.onSuccess { 517 + 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 + } 554 + ) 555 + 556 + then() 557 + } 558 + 494 559 } 495 560 } 496 561 }