A cheap attempt at a native Bluesky client for Android

SkeetView: Render rich text

Parse and display rich text (links, mentions, tags) within a skeet's content. This is done by processing the `facets` data associated with a post.

The `SkeetData` model now includes `facets` and a new `annotatedContent()` method to build an `AnnotatedString` for rendering. The `SkeetView` uses this new method to display the formatted text.

+106 -12
+7 -9
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 63 63 @Composable 64 64 fun SkeetView( 65 65 modifier: Modifier = Modifier, 66 - color: Color = MaterialTheme.colorScheme.surface, 67 66 viewModel: TimelineViewModel? = null, 68 67 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 69 68 skeet: SkeetData, ··· 82 81 } 83 82 84 83 val minSize = 55.dp 85 - 86 - val (parent, _) = skeet.parent() 87 - val hasParent = parent != null 88 84 89 85 Surface( 90 86 color = Color.Transparent, ··· 153 149 ) { 154 150 val context = LocalContext.current 155 151 156 - Text( 157 - text = skeet.content, 158 - color = MaterialTheme.colorScheme.onSurfaceVariant, 159 - style = MaterialTheme.typography.bodyLarge, 160 - ) 152 + if (skeet.content.isNotEmpty()) { 153 + Text( 154 + text = skeet.annotatedContent(), 155 + color = MaterialTheme.colorScheme.onSurfaceVariant, 156 + style = MaterialTheme.typography.bodyLarge, 157 + ) 158 + } 161 159 162 160 if (skeet.embed == null || disableEmbeds) { 163 161 return
+99 -3
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 2 2 3 3 package industries.geesawra.monarch.datalayer 4 4 5 + import androidx.compose.ui.graphics.Color 6 + import androidx.compose.ui.text.AnnotatedString 7 + import androidx.compose.ui.text.LinkAnnotation 8 + import androidx.compose.ui.text.SpanStyle 9 + import androidx.compose.ui.text.TextLinkStyles 10 + import androidx.compose.ui.text.buildAnnotatedString 11 + import androidx.compose.ui.text.withLink 12 + import androidx.compose.ui.text.withStyle 5 13 import app.bsky.actor.ProfileView 6 14 import app.bsky.actor.ProfileViewBasic 7 15 import app.bsky.embed.ExternalView ··· 21 29 import app.bsky.feed.ReplyRef 22 30 import app.bsky.feed.ReplyRefParentUnion 23 31 import app.bsky.feed.ReplyRefRootUnion 32 + import app.bsky.richtext.Facet 33 + import app.bsky.richtext.FacetFeatureUnion 24 34 import com.atproto.label.Label 25 35 import com.atproto.repo.StrongRef 26 36 import kotlinx.datetime.toStdlibInstant ··· 85 95 val reason: FeedViewPostReasonUnion? = null, 86 96 val reply: ReplyRef? = null, 87 97 val createdAt: Instant? = null, 88 - 98 + val facets: List<Facet> = listOf(), 89 99 val blocked: Boolean = false, 90 100 val notFound: Boolean = false, 91 101 val following: Boolean = false, ··· 112 122 embed = post.post.embed, 113 123 reason = post.reason, 114 124 reply = post.reply, 125 + facets = content.facets, 115 126 createdAt = content.createdAt.toStdlibInstant(), 116 127 following = post.post.author.viewer?.following != null, 117 128 follower = post.post.author.viewer?.followedBy != null, ··· 255 266 else -> null 256 267 }, 257 268 // TODO: fix embeds 258 - createdAt = post.createdAt.toStdlibInstant() 269 + createdAt = post.createdAt.toStdlibInstant(), 270 + facets = post.facets, 259 271 ) 260 272 } 261 273 ··· 296 308 embed = embed, 297 309 reason = null, 298 310 reply = null, 299 - createdAt = content.createdAt.toStdlibInstant() 311 + createdAt = content.createdAt.toStdlibInstant(), 312 + facets = content.facets, 313 + ) 314 + } 315 + } 316 + 317 + private sealed class AnnotatedData { 318 + data class NoAnnotation(val data: String) : AnnotatedData() 319 + data class WithAnnotation(val data: Facet, val content: String) : AnnotatedData() 320 + } 321 + 322 + fun annotatedContent(): AnnotatedString { 323 + if (this.facets.isEmpty()) { 324 + return buildAnnotatedString { 325 + append(this@SkeetData.content) 326 + } 327 + } 328 + 329 + val c = this.content.toByteArray(Charsets.UTF_8) 330 + 331 + var lastIdx: Long = 0 332 + val content = mutableListOf<AnnotatedData>() 333 + this.facets.forEach { f -> 334 + content.add( 335 + AnnotatedData.NoAnnotation( 336 + c.slice( 337 + lastIdx.toInt().. 338 + f.index.byteStart.toInt() - 1 339 + ).toByteArray().toString(Charsets.UTF_8) 340 + ) 300 341 ) 342 + content.add( 343 + AnnotatedData.WithAnnotation( 344 + data = f, content = c.slice( 345 + f.index.byteStart.toInt().. 346 + f.index.byteEnd.toInt() - 1 347 + ).toByteArray().toString(Charsets.UTF_8) 348 + ) 349 + ) 350 + lastIdx = f.index.byteEnd 351 + } 352 + 353 + return buildAnnotatedString { 354 + content.forEach { content -> 355 + when (content) { 356 + is AnnotatedData.NoAnnotation -> append(content.data) 357 + is AnnotatedData.WithAnnotation -> { 358 + val f = content.data.features.first() 359 + when (f) { 360 + is FacetFeatureUnion.Link -> withLink( 361 + LinkAnnotation.Url( 362 + f.value.uri.uri, 363 + TextLinkStyles(style = SpanStyle(color = Color.Blue)) 364 + ) 365 + ) { 366 + append(content.content) 367 + } 368 + 369 + is FacetFeatureUnion.Mention -> withLink( 370 + LinkAnnotation.Url( 371 + f.value.did.did, 372 + TextLinkStyles(style = SpanStyle(color = Color.Blue)) 373 + ) 374 + ) { 375 + append( 376 + content.content 377 + ) 378 + } 379 + 380 + is FacetFeatureUnion.Tag -> withStyle( 381 + SpanStyle( 382 + color = Color.Blue 383 + ) 384 + ) 385 + { 386 + append(content.content) 387 + } 388 + 389 + 390 + is FacetFeatureUnion.Unknown -> append( 391 + content.content 392 + ) 393 + } 394 + } 395 + } 396 + } 301 397 } 302 398 } 303 399