A cheap attempt at a native Bluesky client for Android
7
fork

Configure Feed

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

*: Settings page, embed fixes, video aspect ratio, thread facets

- Add settings page with 6 persisted options: theme mode, dynamic color,
post text size, avatar shape, reply filtering, show labels
- Move settings icon from top bar to own profile's top bar
- Thread postTextSize and avatarShape through embeds and compose view
- Fix missing facets in fromPostView (thread non-leaf posts)
- Fix video aspect ratio: use embed data to fill width proportionally
- Hide repost reason and labels in reply preview
- Fix label-to-body padding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

geesawra c302ecb2 d058981c

+65 -23
+8 -1
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 82 82 import app.bsky.richtext.FacetLink 83 83 import app.bsky.richtext.FacetTag 84 84 import com.atproto.repo.StrongRef 85 + import industries.geesawra.monarch.datalayer.AvatarShape 86 + import industries.geesawra.monarch.datalayer.SettingsState 85 87 import industries.geesawra.monarch.datalayer.SkeetData 86 88 import industries.geesawra.monarch.datalayer.TimelineViewModel 87 89 import kotlinx.coroutines.CoroutineScope ··· 91 93 import app.bsky.richtext.FacetMention 92 94 import sh.christian.ozone.api.Did 93 95 import androidx.compose.foundation.shape.CircleShape 96 + import androidx.compose.foundation.shape.RoundedCornerShape 94 97 import androidx.compose.ui.draw.clip 95 98 import androidx.compose.foundation.clickable 96 99 import androidx.compose.foundation.lazy.LazyColumn ··· 115 118 context: Context, 116 119 coroutineScope: CoroutineScope, 117 120 timelineViewModel: TimelineViewModel, 121 + settingsState: SettingsState = SettingsState(), 118 122 inReplyTo: MutableState<SkeetData?>, 119 123 isQuotePost: MutableState<Boolean>, 120 124 scaffoldState: BottomSheetScaffoldState, ··· 264 268 .background(Color.Transparent), 265 269 skeet = it, 266 270 nested = true, 267 - disableEmbeds = false 271 + disableEmbeds = false, 272 + showLabels = false, 273 + showInReplyTo = false, 274 + avatarShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape, 268 275 ) 269 276 } 270 277 }
+3
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 201 201 timelineViewModel.openProfile(did) 202 202 navController.navigate(ViewList.Profile.name) 203 203 }, 204 + onSettingsTap = { 205 + navController.navigate(ViewList.Settings.name) 206 + }, 204 207 ) 205 208 } 206 209 composable(route = ViewList.Settings.name) {
+1 -4
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 29 29 import androidx.compose.material.icons.filled.Notifications 30 30 import androidx.compose.material.icons.filled.PersonSearch 31 31 import androidx.compose.material.icons.filled.Search 32 - import androidx.compose.material.icons.filled.Settings 33 32 import androidx.compose.material.icons.filled.Tag 34 33 import androidx.compose.material3.Badge 35 34 import androidx.compose.material3.BadgedBox ··· 161 160 context = LocalContext.current, 162 161 coroutineScope = coroutineScope, 163 162 timelineViewModel = timelineViewModel, 163 + settingsState = settingsState, 164 164 scaffoldState = scaffoldState, 165 165 scrollState = scrollState, 166 166 inReplyTo = inReplyTo, ··· 396 396 } 397 397 }, 398 398 actions = { 399 - IconButton(onClick = onSettingsTap) { 400 - Icon(Icons.Default.Settings, "Settings") 401 - } 402 399 when (currentDestination) { 403 400 TabBarDestinations.TIMELINE -> { 404 401 if (timelineViewModel.uiState.user == null) {
+7
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 41 41 import androidx.compose.material.icons.filled.MoreVert 42 42 import androidx.compose.material.icons.filled.PersonAdd 43 43 import androidx.compose.material.icons.filled.PersonRemove 44 + import androidx.compose.material.icons.filled.Settings 44 45 import androidx.compose.material.icons.filled.SmartToy 45 46 import androidx.compose.material.icons.filled.Verified 46 47 import androidx.compose.material3.Card ··· 112 113 backButton: () -> Unit, 113 114 onThreadTap: (SkeetData) -> Unit, 114 115 onProfileTap: (Did) -> Unit, 116 + onSettingsTap: () -> Unit = {}, 115 117 ) { 116 118 val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) 117 119 val listState = rememberLazyListState() ··· 188 190 }, 189 191 scrollBehavior = scrollBehavior, 190 192 actions = { 193 + if (profile != null && timelineViewModel.isOwnProfile()) { 194 + IconButton(onClick = onSettingsTap) { 195 + Icon(Icons.Default.Settings, "Settings") 196 + } 197 + } 191 198 if (profile != null && !timelineViewModel.isOwnProfile()) { 192 199 ProfileOverflowMenu(timelineViewModel, profile) 193 200 }
+45 -18
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 8 8 import androidx.browser.customtabs.CustomTabsIntent 9 9 import androidx.compose.foundation.clickable 10 10 import androidx.compose.foundation.layout.Arrangement 11 + import androidx.compose.foundation.layout.aspectRatio 11 12 import androidx.compose.foundation.layout.Column 12 13 import androidx.compose.foundation.layout.ExperimentalLayoutApi 13 14 import androidx.compose.foundation.layout.fillMaxHeight ··· 133 134 } 134 135 .padding(top = 8.dp, start = 10.dp, end = 10.dp, bottom = 8.dp) 135 136 ) { 136 - SkeetReason( 137 - modifier = Modifier.padding(start = 4.dp), 138 - skeet = skeet, 139 - showInReplyTo, 140 - renderingReplyNotif, 141 - renderingMention 142 - ) 137 + if (showInReplyTo) { 138 + SkeetReason( 139 + modifier = Modifier.padding(start = 4.dp), 140 + skeet = skeet, 141 + showInReplyTo, 142 + renderingReplyNotif, 143 + renderingMention 144 + ) 145 + } 143 146 144 147 Row( 145 148 modifier = Modifier ··· 174 177 ) 175 178 } 176 179 177 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize) 180 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 178 181 } 179 182 } else { 180 183 // Top-level posts: two-column layout, thread line spans full height ··· 246 249 labelerAvatar = { viewModel?.labelerAvatar(it) } 247 250 ) 248 251 249 - SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize) 252 + SkeetContent(skeet, nested, disableEmbeds, onShowThread, viewModel, onMentionClick = onAvatarTap, postTextSize = postTextSize, avatarShape = avatarShape) 250 253 251 254 if (!disableEmbeds) { 252 255 TimelinePostActionsView( ··· 273 276 viewModel: TimelineViewModel? = null, 274 277 onMentionClick: ((Did) -> Unit)? = null, 275 278 postTextSize: PostTextSize = PostTextSize.Medium, 279 + avatarShape: Shape = CircleShape, 276 280 ) { 277 281 val context = LocalContext.current 278 282 ··· 293 297 return 294 298 } 295 299 296 - Embeds(context, nested, skeet.embed, onShowThread, viewModel) 300 + Embeds(context, nested, skeet.embed, onShowThread, viewModel, postTextSize, avatarShape) 297 301 } 298 302 299 303 @Composable ··· 303 307 embed: PostViewEmbedUnion?, 304 308 onShowThread: (SkeetData) -> Unit, 305 309 viewModel: TimelineViewModel? = null, 310 + postTextSize: PostTextSize = PostTextSize.Medium, 311 + avatarShape: Shape = CircleShape, 306 312 ) { 307 313 when (embed) { 308 314 is PostViewEmbedUnion.ImagesView -> { ··· 310 316 } 311 317 312 318 is PostViewEmbedUnion.VideoView -> { 313 - VideoView(embed.value.playlist.uri.toUri()) 319 + val ar = embed.value.aspectRatio 320 + VideoView( 321 + embed.value.playlist.uri.toUri(), 322 + aspectRatio = if (ar != null && ar.height > 0) ar.width.toFloat() / ar.height.toFloat() else null 323 + ) 314 324 } 315 325 316 326 is PostViewEmbedUnion.ExternalView -> { ··· 330 340 embed.value, 331 341 onShowThread = onShowThread, 332 342 viewModel = viewModel, 343 + postTextSize = postTextSize, 344 + avatarShape = avatarShape, 333 345 ) 334 346 } 335 347 } ··· 346 358 is RecordWithMediaViewMediaUnion.VideoView -> PostViewEmbedUnion.VideoView(media.value) 347 359 } 348 360 349 - Embeds(context, false, mediaValue, onShowThread, viewModel) 361 + Embeds(context, false, mediaValue, onShowThread, viewModel, postTextSize, avatarShape) 350 362 351 363 OutlinedCard( 352 364 modifier = Modifier.padding(top = 4.dp) ··· 356 368 embed.value, 357 369 onShowThread = onShowThread, 358 370 viewModel = viewModel, 371 + postTextSize = postTextSize, 372 + avatarShape = avatarShape, 359 373 ) 360 374 } 361 375 } ··· 387 401 } 388 402 389 403 @Composable 390 - fun VideoView(uri: Uri) { 404 + fun VideoView(uri: Uri, aspectRatio: Float? = null) { 391 405 OutlinedCard( 392 406 modifier = Modifier 393 - .heightIn(max = 500.dp) 394 407 .fillMaxWidth() 395 408 .padding(top = 8.dp, bottom = 8.dp), 396 409 ) { ··· 420 433 controllerAutoShow = true, 421 434 showFullScreenButton = true, 422 435 ), 423 - volume = 0.5f, // volume 0.0f to 1.0f 424 - repeatMode = RepeatMode.NONE, // or RepeatMode.ALL, RepeatMode.ONE 436 + volume = 0.5f, 437 + repeatMode = RepeatMode.NONE, 425 438 modifier = Modifier 426 - .fillMaxSize() 439 + .fillMaxWidth() 440 + .then( 441 + if (aspectRatio != null) 442 + Modifier.aspectRatio(aspectRatio) 443 + else 444 + Modifier.heightIn(max = 500.dp) 445 + ) 427 446 ) 428 447 } 429 448 } ··· 520 539 rv: RecordView, 521 540 onShowThread: (SkeetData) -> Unit, 522 541 viewModel: TimelineViewModel? = null, 542 + postTextSize: PostTextSize = PostTextSize.Medium, 543 + avatarShape: Shape = CircleShape, 523 544 ) { 524 545 val rv = rv.record 525 546 when (rv) { ··· 531 552 viewModel = viewModel, 532 553 skeet = s, 533 554 nested = true, 555 + postTextSize = postTextSize, 556 + avatarShape = avatarShape, 534 557 onShowThread = onShowThread 535 558 ) 536 559 } ··· 545 568 rv: RecordWithMediaView, 546 569 onShowThread: (SkeetData) -> Unit, 547 570 viewModel: TimelineViewModel? = null, 571 + postTextSize: PostTextSize = PostTextSize.Medium, 572 + avatarShape: Shape = CircleShape, 548 573 ) { 549 574 val rv = rv.record.record 550 575 val record = when (rv) { ··· 564 589 viewModel = viewModel, 565 590 skeet = record, 566 591 nested = true, 592 + postTextSize = postTextSize, 593 + avatarShape = avatarShape, 567 594 onShowThread = onShowThread 568 595 ) 569 596 } ··· 769 796 FlowRow( 770 797 horizontalArrangement = Arrangement.spacedBy(6.dp), 771 798 verticalArrangement = Arrangement.spacedBy(4.dp), 772 - modifier = Modifier.padding(top = 6.dp) 799 + modifier = Modifier.padding(top = 2.dp, bottom = 4.dp) 773 800 ) { 774 801 skeet.authorLabels.forEach { 775 802 it.neg?.let { it ->
+1
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 236 236 authorLabels = post.author.labels, 237 237 verified = post.author.verification?.verifiedStatus == VerifiedStatus.Valid, 238 238 content = content.text, 239 + facets = content.facets, 239 240 embed = post.embed, 240 241 createdAt = content.createdAt.toStdlibInstant(), 241 242 following = author.viewer?.following != null,