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.

*: Content warnings, repost profile nav, consistent loading indicators

- Add post-level label support and content warning cards that hide
sensitive content behind a Show/Hide toggle for porn, sexual, nudity,
graphic-media, and gore labels
- Tapping "Reposted by" navigates to the reposter's profile
- Hide repost reason and labels in compose reply preview
- Pass postTextSize to compose view reply preview
- Back gesture/button dismisses compose bottom sheet
- Replace CircularProgressIndicator with CircularWavyProgressIndicator
in ProfileView and SearchView for consistent M3 expressive loading

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

geesawra 0634bf21 c302ecb2

+100 -12
+1
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 272 272 showLabels = false, 273 273 showInReplyTo = false, 274 274 avatarShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape, 275 + postTextSize = settingsState.postTextSize, 275 276 ) 276 277 } 277 278 }
+7
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 3 3 package industries.geesawra.monarch 4 4 5 5 import android.widget.Toast 6 + import androidx.activity.compose.BackHandler 6 7 import androidx.annotation.StringRes 7 8 import androidx.compose.animation.AnimatedVisibility 8 9 import androidx.compose.animation.core.animate ··· 145 146 146 147 LaunchedEffect(Unit) { 147 148 onFirstLoad() 149 + } 150 + 151 + BackHandler(enabled = scaffoldState.bottomSheetState.isVisible) { 152 + coroutineScope.launch { 153 + scaffoldState.bottomSheetState.hide() 154 + } 148 155 } 149 156 150 157 BottomSheetScaffold(
+7 -6
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 45 45 import androidx.compose.material.icons.filled.SmartToy 46 46 import androidx.compose.material.icons.filled.Verified 47 47 import androidx.compose.material3.Card 48 - import androidx.compose.material3.CircularProgressIndicator 48 + import androidx.compose.material3.CircularWavyProgressIndicator 49 49 import androidx.compose.material3.DropdownMenu 50 50 import androidx.compose.material3.DropdownMenuItem 51 51 import androidx.compose.material3.ExperimentalMaterial3Api 52 + import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 52 53 import androidx.compose.material3.FilledTonalButton 53 54 import androidx.compose.material3.FilledTonalIconButton 54 55 import androidx.compose.material3.Button ··· 103 104 import kotlin.time.ExperimentalTime 104 105 105 106 106 - @OptIn(ExperimentalMaterial3Api::class) 107 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) 107 108 @Composable 108 109 fun ProfileView( 109 110 modifier: Modifier = Modifier, ··· 253 254 } 254 255 } 255 256 257 + @OptIn(ExperimentalMaterial3ExpressiveApi::class) 256 258 @Composable 257 259 private fun ProfileContent( 258 260 modifier: Modifier = Modifier, ··· 315 317 .padding(16.dp), 316 318 contentAlignment = Alignment.Center, 317 319 ) { 318 - CircularProgressIndicator() 320 + CircularWavyProgressIndicator() 319 321 } 320 322 } 321 323 } ··· 582 584 } 583 585 } 584 586 585 - @OptIn(ExperimentalMaterial3Api::class) 587 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) 586 588 @Composable 587 589 private fun EditProfileSheet( 588 590 profile: ProfileViewDetailed, ··· 746 748 enabled = !isSaving, 747 749 ) { 748 750 if (isSaving) { 749 - CircularProgressIndicator( 751 + CircularWavyProgressIndicator( 750 752 modifier = Modifier.size(18.dp), 751 - strokeWidth = 2.dp, 752 753 ) 753 754 Spacer(Modifier.width(8.dp)) 754 755 }
+6 -3
app/src/main/java/industries/geesawra/monarch/SearchView.kt
··· 23 23 import androidx.compose.material.icons.filled.Search 24 24 import androidx.compose.material.icons.filled.Verified 25 25 import androidx.compose.material3.Card 26 - import androidx.compose.material3.CircularProgressIndicator 26 + import androidx.compose.material3.CircularWavyProgressIndicator 27 27 import androidx.compose.material3.ExperimentalMaterial3Api 28 + import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 28 29 import androidx.compose.material3.Icon 29 30 import androidx.compose.material3.IconButton 30 31 import androidx.compose.material3.MaterialTheme ··· 167 168 } 168 169 169 170 171 + @OptIn(ExperimentalMaterial3ExpressiveApi::class) 170 172 @Composable 171 173 private fun SearchPostsResults( 172 174 viewModel: TimelineViewModel, ··· 240 242 .padding(16.dp), 241 243 contentAlignment = Alignment.Center, 242 244 ) { 243 - CircularProgressIndicator() 245 + CircularWavyProgressIndicator() 244 246 } 245 247 } 246 248 } ··· 262 264 } 263 265 } 264 266 267 + @OptIn(ExperimentalMaterial3ExpressiveApi::class) 265 268 @Composable 266 269 private fun SearchPeopleResults( 267 270 viewModel: TimelineViewModel, ··· 324 327 .padding(16.dp), 325 328 contentAlignment = Alignment.Center, 326 329 ) { 327 - CircularProgressIndicator() 330 + CircularWavyProgressIndicator() 328 331 } 329 332 } 330 333 }
+76 -3
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 47 47 import androidx.compose.material3.ModalBottomSheet 48 48 import androidx.compose.material3.Surface 49 49 import androidx.compose.material3.Text 50 + import androidx.compose.material3.TextButton 50 51 import androidx.compose.material3.rememberModalBottomSheetState 51 52 import androidx.compose.runtime.mutableStateOf 52 53 import androidx.compose.runtime.remember ··· 121 122 return 122 123 } 123 124 125 + val warningLabel = skeet.postLabels.firstOrNull { it.`val` in contentWarningLabels } 126 + if (warningLabel != null) { 127 + var revealed by remember { mutableStateOf(false) } 128 + val definition = labelDefinition(warningLabel.`val`) 129 + 130 + if (!revealed) { 131 + ContentWarningCard( 132 + label = definition.plaintext, 133 + onShow = { revealed = true }, 134 + wrapWithCard = !nested, 135 + ) 136 + return 137 + } 138 + } 139 + 124 140 val minSize = 40.dp 125 141 126 142 if (nested) { ··· 140 156 skeet = skeet, 141 157 showInReplyTo, 142 158 renderingReplyNotif, 143 - renderingMention 159 + renderingMention, 160 + onAvatarTap = onAvatarTap, 144 161 ) 145 162 } 146 163 ··· 238 255 skeet = skeet, 239 256 showInReplyTo, 240 257 renderingReplyNotif, 241 - renderingMention 258 + renderingMention, 259 + onAvatarTap = onAvatarTap, 242 260 ) 243 261 244 262 SkeetHeader( ··· 603 621 showInReplyTo: Boolean = true, 604 622 renderingReplyNotif: Boolean = false, 605 623 renderingMention: Boolean = false, 624 + onAvatarTap: ((Did) -> Unit)? = null, 606 625 ) { 607 626 Column(modifier = modifier) { 608 627 if (renderingMention) { ··· 638 657 Row( 639 658 modifier = Modifier 640 659 .fillMaxWidth() 641 - .padding(bottom = 8.dp), 660 + .padding(bottom = 8.dp) 661 + .then( 662 + if (onAvatarTap != null) 663 + Modifier.clickable { onAvatarTap(it.value.by.did) } 664 + else Modifier 665 + ), 642 666 verticalAlignment = Alignment.CenterVertically 643 667 ) { 644 668 Icon( ··· 702 726 private data class LabelDefinition( 703 727 val plaintext: String, 704 728 val icon: ImageVector, 729 + ) 730 + 731 + private val contentWarningLabels = setOf( 732 + "porn", "sexual", "nudity", "sexual-figurative", 733 + "graphic-media", "gore", 705 734 ) 706 735 707 736 private val knownLabels: Map<String, LabelDefinition> = mapOf( ··· 896 925 } 897 926 } 898 927 928 + } 929 + } 930 + 931 + @Composable 932 + private fun ContentWarningCard( 933 + label: String, 934 + onShow: () -> Unit, 935 + wrapWithCard: Boolean = true, 936 + ) { 937 + val content = @Composable { 938 + Row( 939 + modifier = Modifier 940 + .fillMaxWidth() 941 + .padding(horizontal = 16.dp, vertical = 12.dp), 942 + verticalAlignment = Alignment.CenterVertically, 943 + ) { 944 + Icon( 945 + imageVector = Icons.Filled.Warning, 946 + contentDescription = null, 947 + modifier = Modifier.size(20.dp), 948 + tint = MaterialTheme.colorScheme.onSurfaceVariant, 949 + ) 950 + Spacer(modifier = Modifier.width(8.dp)) 951 + Text( 952 + text = label, 953 + style = MaterialTheme.typography.titleSmall, 954 + modifier = Modifier.weight(1f), 955 + ) 956 + TextButton(onClick = onShow) { 957 + Text("Show") 958 + } 959 + } 960 + } 961 + 962 + if (wrapWithCard) { 963 + OutlinedCard( 964 + modifier = Modifier 965 + .fillMaxWidth() 966 + .padding(8.dp) 967 + ) { 968 + content() 969 + } 970 + } else { 971 + content() 899 972 } 900 973 }
+3
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 108 108 val reply: ReplyRef? = null, 109 109 val createdAt: Instant? = null, 110 110 val facets: List<Facet> = listOf(), 111 + val postLabels: List<Label> = listOf(), 111 112 val blocked: Boolean = false, 112 113 val notFound: Boolean = false, 113 114 val following: Boolean = false, ··· 130 131 authorName = post.post.author.displayName, 131 132 authorHandle = post.post.author.handle, 132 133 authorLabels = post.post.author.labels, 134 + postLabels = post.post.labels, 133 135 verified = post.post.author.verification?.verifiedStatus == VerifiedStatus.Valid, 134 136 content = content.text, 135 137 embed = post.post.embed, ··· 234 236 authorName = post.author.displayName, 235 237 authorHandle = post.author.handle, 236 238 authorLabels = post.author.labels, 239 + postLabels = post.labels, 237 240 verified = post.author.verification?.verifiedStatus == VerifiedStatus.Valid, 238 241 content = content.text, 239 242 facets = content.facets,