A cheap attempt at a native Bluesky client for Android

ComposeView: basic facet rendering

Renders URLs, @-mentions and hashtags, sends URLs and hashtags facets.

Still need a way to add embeds on-demand for urls, but that's kind of done already IMO.

@-mentions are harder because I need to add on-the-fly search with a dropdown menu.

+102 -19
+93 -16
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 72 72 import androidx.compose.ui.text.input.TextFieldValue 73 73 import androidx.compose.ui.text.withStyle 74 74 import androidx.compose.ui.unit.dp 75 - import androidx.compose.ui.util.fastForEachIndexed 75 + import app.bsky.richtext.Facet 76 + import app.bsky.richtext.FacetByteSlice 77 + import app.bsky.richtext.FacetFeatureUnion 78 + import app.bsky.richtext.FacetLink 79 + import app.bsky.richtext.FacetTag 76 80 import com.atproto.repo.StrongRef 77 81 import industries.geesawra.monarch.datalayer.SkeetData 78 82 import industries.geesawra.monarch.datalayer.TimelineViewModel ··· 97 101 val wasEdited = remember { mutableStateOf(false) } 98 102 val maxChars = 300 99 103 val composeFieldState = remember { mutableStateOf(TextFieldValue("")) } 104 + val facets = remember { mutableListOf<Facet>() } 100 105 val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 101 106 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 102 107 ··· 243 248 .contentReceiver(receiveContentListener), 244 249 value = composeFieldState.value, 245 250 onValueChange = { 251 + val a = annotated(it.text, urlColor) 252 + facets.clear() 253 + facets.addAll(a.facets) 246 254 composeFieldState.value = 247 - it.copy(annotatedString = annotated(it.text, urlColor)) 255 + it.copy(annotatedString = a.annotated) 248 256 }, 249 257 keyboardOptions = KeyboardOptions( 250 258 capitalization = KeyboardCapitalization.Sentences, ··· 279 287 timelineViewModel, 280 288 scaffoldState, 281 289 inReplyTo.value, 282 - isQuotePost.value 290 + isQuotePost.value, 291 + facets = facets 283 292 ) 284 293 285 294 Spacer(modifier = Modifier.height(8.dp)) ··· 342 351 scaffoldState: BottomSheetScaffoldState, 343 352 inReplyToData: SkeetData? = null, 344 353 isQuotePost: Boolean = false, 354 + facets: List<Facet> = listOf() 345 355 ) { 346 356 347 357 Row( ··· 376 386 uploadingPost.value = true // Show progress immediately 377 387 timelineViewModel.post( 378 388 content = postText, 389 + facets = facets, 379 390 images = if (!mediaSelectedIsVideo.value) mediaSelected.value 380 391 .ifEmpty { null } else null, 381 392 video = if (mediaSelectedIsVideo.value) mediaSelected.value.firstOrNull() else null, ··· 423 434 } 424 435 } 425 436 426 - fun annotated(data: String, urlColor: Color): AnnotatedString { 427 - return buildAnnotatedString { 428 - val split = data.split(" ") 429 - split.fastForEachIndexed { idx, s -> 430 - if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s) || s.startsWith("#") || s.startsWith("@")) { 431 - withStyle( 432 - SpanStyle( 433 - color = urlColor 437 + private data class FacetString( 438 + val annotated: AnnotatedString, 439 + val facets: List<Facet> 440 + ) 441 + 442 + private fun annotated(data: String, urlColor: Color): FacetString { 443 + val facets = mutableListOf<Facet>() 444 + val annotatedString = buildAnnotatedString { 445 + val tokens = Regex("(\\S+)").findAll(data) 446 + var lastIndex = 0 447 + 448 + for (token in tokens) { 449 + if (token.range.first > lastIndex) { 450 + append(data.substring(lastIndex, token.range.first)) 451 + } 452 + 453 + val s = token.value 454 + val startByte = 455 + data.substring(0, token.range.first).encodeToByteArray().size 456 + val endByte = 457 + data.substring(0, token.range.last + 1).encodeToByteArray().size 458 + 459 + if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) { 460 + withStyle(SpanStyle(color = urlColor)) { append(s) } 461 + facets.add( 462 + Facet( 463 + index = FacetByteSlice(startByte.toLong(), endByte.toLong()), 464 + features = listOf( 465 + FacetFeatureUnion.Link( 466 + value = FacetLink( 467 + uri = sh.christian.ozone.api.Uri(s) 468 + ) 469 + ) 470 + ) 434 471 ) 435 472 ) 436 - { 437 - append(s) 473 + } else if (s.startsWith("#") && s.length > 1) { 474 + withStyle(SpanStyle(color = urlColor)) { append(s) } 475 + val tag = s.substring(1) 476 + if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 477 + facets.add( 478 + Facet( 479 + index = FacetByteSlice( 480 + startByte.toLong(), 481 + endByte.toLong() 482 + ), 483 + features = listOf( 484 + FacetFeatureUnion.Tag( 485 + value = FacetTag( 486 + tag = s.removePrefix("#"), 487 + ) 488 + ) 489 + ) 490 + ) 491 + ) 438 492 } 493 + } else if (s.startsWith("@") && s.length > 1) { 494 + withStyle(SpanStyle(color = urlColor)) { append(s) } 495 + // TODO: mentions go here, need DID resolution 496 + // val tag = s.substring(1) 497 + // if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 498 + // facets.add( 499 + // Facet( 500 + // index = FacetByteSlice( 501 + // startByte.toLong(), 502 + // endByte.toLong() 503 + // ), 504 + // features = listOf( 505 + // FacetFeatureUnion.Mention( 506 + // value = FacetMention( 507 + // tag = s, 508 + // ) 509 + // ) 510 + // ) 511 + // ) 512 + // ) 513 + // } 439 514 } else { 440 515 append(s) 441 516 } 517 + lastIndex = token.range.last + 1 518 + } 442 519 443 - if (idx < split.size - 1) { 444 - append(" ") 445 - } 520 + if (lastIndex < data.length) { 521 + append(data.substring(lastIndex)) 446 522 } 447 523 } 524 + return FacetString(annotated = annotatedString, facets = facets) 448 525 }
+5 -2
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 44 44 import app.bsky.notification.ListNotificationsQueryParams 45 45 import app.bsky.notification.ListNotificationsResponse 46 46 import app.bsky.notification.UpdateSeenRequest 47 + import app.bsky.richtext.Facet 47 48 import app.bsky.video.GetJobStatusQueryParams 48 49 import app.bsky.video.GetJobStatusResponse 49 50 import app.bsky.video.JobStatus ··· 548 549 images: List<Uri>? = null, 549 550 video: Uri? = null, 550 551 replyRef: PostReplyRef? = null, 551 - quotePostRef: StrongRef? = null 552 + quotePostRef: StrongRef? = null, 553 + facets: List<Facet> = listOf(), 552 554 ): Result<Unit> { 553 555 return runCatching { 554 556 create().onFailure { ··· 595 597 text = content, 596 598 createdAt = Clock.System.now().toDeprecatedInstant(), 597 599 embed = postEmbed, 598 - reply = replyRef 600 + reply = replyRef, 601 + facets = facets, 599 602 ) 600 603 ) 601 604
+4 -1
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 20 20 import app.bsky.feed.ThreadViewPostReplieUnion 21 21 import app.bsky.graph.Follow 22 22 import app.bsky.notification.ListNotificationsReason 23 + import app.bsky.richtext.Facet 23 24 import com.atproto.repo.StrongRef 24 25 import dagger.assisted.Assisted 25 26 import dagger.assisted.AssistedFactory ··· 459 460 video: Uri? = null, 460 461 replyRef: PostReplyRef? = null, 461 462 quotePostRef: StrongRef? = null, 463 + facets: List<Facet> = listOf(), 462 464 ): Result<Unit> { 463 465 return bskyConn.post( 464 466 content, 465 467 images, 466 468 video, 467 469 replyRef, 468 - quotePostRef 470 + quotePostRef, 471 + facets 469 472 ) // TODO: maybe refactor this to use uistate.Error? 470 473 } 471 474