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.

ComposeView: Replace mention dropdown with custom OutlinedCard list

Refactor the user mention suggestions UI to use a custom `OutlinedCard` containing a `LazyColumn` instead of the standard `DropdownMenu`. This provides a more tailored look and feel, including profile avatars in the search results.

- Replace `DropdownMenu` and `DropdownMenuItem` with `OutlinedCard`, `LazyColumn`, and custom `Row` items.
- Include user avatars in the mention results using `AsyncImage`.
- Switch `mentionDids` to `mutableStateMapOf` for better state tracking in Compose.
- Reduce the search typeahead limit from 8 to 5 in the `Bluesky` data layer.
- Cleanup: Remove large block of commented-out `OutlinedTextField` code.
- Dependency Update: Add `androidx.compose.foundation` to support the new UI components.

geesawra 78685f20 713d3218

+91 -79
+1
app/build.gradle.kts
··· 94 94 implementation("nl.jacobras:Human-Readable:1.12.0") 95 95 implementation(libs.androidx.compose.animation.core.lint) 96 96 implementation(libs.androidx.material3) 97 + implementation(libs.androidx.compose.foundation) 97 98 ksp("com.google.dagger:hilt-compiler:2.57.2") 98 99 implementation(libs.androidx.core.ktx) 99 100 implementation(libs.androidx.lifecycle.runtime.ktx)
+87 -78
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 62 62 import androidx.compose.runtime.MutableState 63 63 import androidx.compose.runtime.mutableIntStateOf 64 64 import androidx.compose.runtime.mutableStateOf 65 + import androidx.compose.runtime.mutableStateMapOf 65 66 import androidx.compose.runtime.remember 66 67 import androidx.compose.ui.Alignment 67 68 import androidx.compose.ui.Modifier ··· 89 90 import app.bsky.actor.ProfileViewBasic 90 91 import app.bsky.richtext.FacetMention 91 92 import sh.christian.ozone.api.Did 92 - import androidx.compose.material3.DropdownMenu 93 - import androidx.compose.material3.DropdownMenuItem 93 + import androidx.compose.foundation.shape.CircleShape 94 + import androidx.compose.ui.draw.clip 95 + import androidx.compose.foundation.clickable 96 + import androidx.compose.foundation.lazy.LazyColumn 97 + import androidx.compose.foundation.lazy.items 98 + import androidx.compose.foundation.rememberScrollState 94 99 import industries.geesawra.monarch.datalayer.LinkPreviewData 95 100 import industries.geesawra.monarch.datalayer.LinkPreviewFetcher 96 101 import coil3.compose.AsyncImage ··· 126 131 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 127 132 val mentionResults = remember { mutableStateOf(listOf<ProfileViewBasic>()) } 128 133 val showMentionDropdown = remember { mutableStateOf(false) } 129 - val mentionDids = remember { mutableMapOf<String, Did>() } 134 + val mentionDids = remember { mutableStateMapOf<String, Did>() } 130 135 131 136 val linkPreview = remember { mutableStateOf<LinkPreviewData?>(null) } 132 137 val linkPreviewLoading = remember { mutableStateOf(false) } ··· 432 437 outputTransformation = facetHighlighter, 433 438 ) 434 439 435 - DropdownMenu( 436 - expanded = showMentionDropdown.value, 437 - onDismissRequest = { showMentionDropdown.value = false }, 438 - ) { 439 - mentionResults.value.forEach { profile -> 440 - DropdownMenuItem( 441 - text = { 442 - Column { 443 - profile.displayName?.let { name -> 440 + if (showMentionDropdown.value) { 441 + OutlinedCard( 442 + modifier = Modifier 443 + .fillMaxWidth() 444 + .padding(vertical = 4.dp) 445 + .heightIn(max = 200.dp) 446 + ) { 447 + LazyColumn { 448 + items(mentionResults.value) { profile -> 449 + Row( 450 + modifier = Modifier 451 + .fillMaxWidth() 452 + .clickable { 453 + val text = textfieldState.text.toString() 454 + val cursorPos = textfieldState.selection.min 455 + val textBeforeCursor = 456 + text.substring(0, cursorPos) 457 + val atIndex = 458 + textBeforeCursor.lastIndexOf('@') 459 + 460 + if (atIndex >= 0) { 461 + val fullHandle = profile.handle.handle 462 + val replacement = "@$fullHandle " 463 + val afterCursor = 464 + text.substring(cursorPos) 465 + val newText = 466 + text.substring(0, atIndex) + replacement + afterCursor 467 + 468 + textfieldState.edit { 469 + replace(0, length, newText) 470 + val newCursorPos = 471 + atIndex + replacement.length 472 + selection = TextRange( 473 + newCursorPos, 474 + newCursorPos 475 + ) 476 + } 477 + 478 + mentionDids[fullHandle] = profile.did 479 + } 480 + 481 + showMentionDropdown.value = false 482 + } 483 + .padding(horizontal = 12.dp, vertical = 8.dp), 484 + verticalAlignment = Alignment.CenterVertically, 485 + horizontalArrangement = Arrangement.spacedBy(12.dp), 486 + ) { 487 + AsyncImage( 488 + model = ImageRequest.Builder(LocalContext.current) 489 + .data(profile.avatar?.uri) 490 + .build(), 491 + contentDescription = "${profile.displayName ?: profile.handle.handle}'s avatar", 492 + contentScale = ContentScale.Crop, 493 + modifier = Modifier 494 + .size(32.dp) 495 + .clip(CircleShape), 496 + ) 497 + Column { 498 + profile.displayName?.let { name -> 499 + Text( 500 + text = name, 501 + style = MaterialTheme.typography.bodyMedium, 502 + ) 503 + } 444 504 Text( 445 - text = name, 446 - style = MaterialTheme.typography.bodyMedium, 505 + text = "@${profile.handle.handle}", 506 + style = MaterialTheme.typography.bodySmall, 507 + color = MaterialTheme.colorScheme.onSurfaceVariant, 447 508 ) 448 509 } 449 - Text( 450 - text = "@${profile.handle.handle}", 451 - style = MaterialTheme.typography.bodySmall, 452 - color = MaterialTheme.colorScheme.onSurfaceVariant, 453 - ) 454 510 } 455 - }, 456 - onClick = { 457 - val text = textfieldState.text.toString() 458 - val cursorPos = textfieldState.selection.min 459 - val textBeforeCursor = text.substring(0, cursorPos) 460 - val atIndex = textBeforeCursor.lastIndexOf('@') 461 - 462 - if (atIndex >= 0) { 463 - val fullHandle = profile.handle.handle 464 - val replacement = "@$fullHandle " 465 - val afterCursor = text.substring(cursorPos) 466 - val newText = text.substring(0, atIndex) + replacement + afterCursor 467 - 468 - textfieldState.edit { 469 - replace(0, length, newText) 470 - val newCursorPos = atIndex + replacement.length 471 - selection = TextRange(newCursorPos, newCursorPos) 472 - } 473 - 474 - mentionDids[fullHandle] = profile.did 475 - } 476 - 477 - showMentionDropdown.value = false 478 511 } 479 - ) 512 + } 480 513 } 481 514 } 482 515 483 - // OutlinedTextField( 484 - // modifier = Modifier 485 - // .fillMaxWidth() 486 - // .heightIn(min = 250.dp) 487 - // .focusRequester(focusRequester) 488 - // .contentReceiver(receiveContentListener), 489 - // value = composeFieldState.value, 490 - // onValueChange = { 491 - // val a = annotated(it.text, urlColor) 492 - // facets.clear() 493 - // facets.addAll(a.facets) 494 - // composeFieldState.value = 495 - // it.copy(annotatedString = a.annotated) 496 - // }, 497 - // keyboardOptions = KeyboardOptions( 498 - // capitalization = KeyboardCapitalization.Sentences, 499 - // keyboardType = KeyboardType.Text, 500 - // ), 501 - // label = { 502 - // if (wasEdited.value) { 503 - // Text( 504 - // text = "${maxChars - charCount.intValue}", 505 - // color = if (composeFieldState.value.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 506 - // ) 507 - // } else { 508 - // Text( 509 - // text = "Less cringe this time, okay?", 510 - // ) 511 - // } 512 - // }, 513 - // isError = composeFieldState.value.text.length > maxChars, 514 - // maxLines = 10, 515 - // ) 516 - 517 516 // Link preview card 518 517 if (linkPreviewLoading.value) { 519 518 OutlinedCard( ··· 545 544 AsyncImage( 546 545 model = ImageRequest.Builder(LocalContext.current) 547 546 .data(imgUrl) 548 - .crossfade(true) 549 547 .build(), 550 548 contentScale = ContentScale.Crop, 551 549 contentDescription = "Link preview thumbnail", ··· 771 769 } 772 770 773 771 val tokensRegexp = Regex("(\\S+)") 772 + 773 + private val bareUrlRegex = Regex("^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\\.[a-zA-Z]{2,}(/\\S*)?$") 774 + 775 + private fun isUrl(s: String): Boolean { 776 + return URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s) || bareUrlRegex.matches(s) 777 + } 778 + 779 + private fun normalizeUrl(s: String): String { 780 + if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) return s 781 + return "https://$s" 782 + } 774 783 775 784 private fun readFacets(data: String, mentionDids: Map<String, Did> = emptyMap()): List<Facet> { 776 785 val facets = mutableListOf<Facet>()
+1 -1
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 1121 1121 val res = client!!.searchActorsTypeahead( 1122 1122 SearchActorsTypeaheadQueryParams( 1123 1123 q = query, 1124 - limit = 8, 1124 + limit = 5, 1125 1125 ) 1126 1126 ) 1127 1127
+2
gradle/libs.versions.toml
··· 10 10 composeBom = "2025.10.01" 11 11 animationCoreLint = "1.9.4" 12 12 material3 = "1.5.0-alpha07" 13 + foundation = "1.10.6" 13 14 14 15 [libraries] 15 16 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } ··· 28 29 androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 29 30 androidx-compose-animation-core-lint = { group = "androidx.compose.animation", name = "animation-core-lint", version.ref = "animationCoreLint" } 30 31 androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } 32 + androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } 31 33 32 34 [plugins] 33 35 android-application = { id = "com.android.application", version.ref = "agp" }