A cheap attempt at a native Bluesky client for Android

NotificationsView: render complete skeet in like/reposts view

+172 -43
+17 -7
app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 28 28 import androidx.compose.material3.Icon 29 29 import androidx.compose.material3.IconButton 30 30 import androidx.compose.material3.MaterialTheme 31 + import androidx.compose.material3.OutlinedCard 31 32 import androidx.compose.material3.Surface 32 33 import androidx.compose.material3.Text 33 34 import androidx.compose.runtime.Composable ··· 49 50 import coil3.request.crossfade 50 51 import industries.geesawra.monarch.datalayer.RepeatableNotification 51 52 import industries.geesawra.monarch.datalayer.RepeatedNotification 53 + import industries.geesawra.monarch.datalayer.SkeetData 52 54 import nl.jacobras.humanreadable.HumanReadable 53 55 import kotlin.time.ExperimentalTime 54 56 ··· 66 68 @Composable 67 69 fun LikeRepostRowView( 68 70 modifier: Modifier = Modifier, 69 - data: RepeatedNotification 71 + data: RepeatedNotification, 72 + onShowThread: (SkeetData) -> Unit = {}, 70 73 ) { 71 74 val minSize = 24.dp 72 75 val showAvatars = remember { mutableStateOf(false) } ··· 282 285 textAlign = TextAlign.End, 283 286 modifier = Modifier.fillMaxWidth() 284 287 ) 285 - Text( 286 - modifier = Modifier.fillMaxWidth(), 287 - text = data.post.text, 288 - color = MaterialTheme.colorScheme.secondary, 289 - style = MaterialTheme.typography.bodySmall, 290 - ) 288 + 289 + OutlinedCard( 290 + modifier = Modifier.padding(top = 8.dp) 291 + ) { 292 + SkeetView( 293 + modifier = Modifier.padding(bottom = 8.dp), 294 + viewModel = null, 295 + skeet = data.post, 296 + nested = true, 297 + showLabels = false, 298 + onShowThread = onShowThread, 299 + ) 300 + } 291 301 } 292 302 } 293 303 }
+2 -1
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 540 540 modifier = Modifier, 541 541 isScrollEnabled = isScrollEnabled, 542 542 onReplyTap = onReplyTap, 543 - scaffoldPadding = values 543 + scaffoldPadding = values, 544 + onSeeMoreTap = onSeeMoreTap 544 545 ) 545 546 } 546 547 }
+11 -1
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 29 29 modifier: Modifier = Modifier, 30 30 isScrollEnabled: Boolean, 31 31 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 32 + onSeeMoreTap: ((SkeetData) -> Unit)? = null, 32 33 scaffoldPadding: PaddingValues 33 34 ) { 34 35 LaunchedEffect(Unit) { ··· 58 59 RenderNotification( 59 60 viewModel = viewModel, 60 61 notification = notif, 61 - onReplyTap = onReplyTap 62 + onReplyTap = onReplyTap, 63 + onShowThread = { skeet -> 64 + if (onSeeMoreTap != null) { 65 + viewModel.setThread(skeet) 66 + onSeeMoreTap(skeet) 67 + } 68 + } 62 69 ) 63 70 } 64 71 } ··· 90 97 viewModel: TimelineViewModel, 91 98 notification: Notification, 92 99 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 100 + onShowThread: (SkeetData) -> Unit = {}, 93 101 ) { 94 102 when (notification) { 95 103 is Notification.Follow -> SkeetView( ··· 103 111 104 112 is Notification.Like -> LikeRepostRowView( 105 113 data = notification.data, 114 + onShowThread = onShowThread, 106 115 ) 107 116 108 117 is Notification.Mention -> SkeetView( ··· 137 146 138 147 is Notification.Repost -> LikeRepostRowView( 139 148 data = notification.data, 149 + onShowThread = onShowThread, 140 150 ) 141 151 142 152
+30 -23
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 71 71 disableEmbeds: Boolean = false, 72 72 inThread: Boolean = false, 73 73 showInReplyTo: Boolean = true, 74 + showLabels: Boolean = true, 74 75 onShowThread: (SkeetData) -> Unit = {}, 75 76 ) { 76 77 if (skeet.blocked) { ··· 126 127 .clip(CircleShape) 127 128 ) 128 129 129 - SkeetHeader(modifier = Modifier.padding(start = 16.dp), skeet = skeet) 130 + SkeetHeader( 131 + modifier = Modifier.padding(start = 16.dp), 132 + skeet = skeet, 133 + showLabels 134 + ) 130 135 } 131 136 132 137 SkeetContent(skeet, nested, disableEmbeds, onShowThread) ··· 364 369 } 365 370 366 371 @Composable 367 - private fun RecordView( 372 + fun RecordView( 368 373 modifier: Modifier = Modifier, 369 374 rv: RecordView, 370 375 onShowThread: (SkeetData) -> Unit ··· 470 475 471 476 @OptIn(ExperimentalLayoutApi::class) 472 477 @Composable 473 - private fun SkeetHeader(modifier: Modifier = Modifier, skeet: SkeetData) { 478 + private fun SkeetHeader(modifier: Modifier = Modifier, skeet: SkeetData, showLabels: Boolean) { 474 479 val authorName = skeet.authorName ?: (skeet.authorHandle?.handle ?: "") 475 480 476 481 Column(modifier = modifier) { ··· 487 492 style = MaterialTheme.typography.bodySmall, 488 493 ) 489 494 490 - FlowRow( 491 - horizontalArrangement = Arrangement.spacedBy(4.dp), 492 - modifier = Modifier.padding(top = 4.dp) 493 - ) { 494 - skeet.authorLabels.forEach { 495 - it.neg?.let { it -> 496 - if (!it) { 495 + if (showLabels) { 496 + FlowRow( 497 + horizontalArrangement = Arrangement.spacedBy(4.dp), 498 + modifier = Modifier.padding(top = 4.dp) 499 + ) { 500 + skeet.authorLabels.forEach { 501 + it.neg?.let { it -> 502 + if (!it) { 503 + return@forEach 504 + } 505 + } 506 + if (it.`val`.startsWith("!")) { 497 507 return@forEach 498 508 } 499 - } 500 - if (it.`val`.startsWith("!")) { 501 - return@forEach 502 - } 503 509 504 - OutlinedCard( 505 - modifier = Modifier.padding(end = 4.dp, bottom = 4.dp), 506 - shape = CircleShape 507 - ) { 508 - Text( 509 - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 510 - text = it.`val`, 511 - style = TextStyle(fontSize = 12.sp), 512 - ) 510 + OutlinedCard( 511 + modifier = Modifier.padding(end = 4.dp, bottom = 4.dp), 512 + shape = CircleShape 513 + ) { 514 + Text( 515 + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 516 + text = it.`val`, 517 + style = TextStyle(fontSize = 12.sp), 518 + ) 519 + } 513 520 } 514 521 } 515 522 }
+98 -6
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 284 284 ) 285 285 } 286 286 287 + fun fromPost(parent: Pair<Cid, AtUri>, post: Post, author: ProfileViewBasic): SkeetData { 288 + return SkeetData( 289 + cid = parent.first, 290 + uri = parent.second, 291 + authorAvatarURL = author.avatar?.uri, 292 + authorName = author.displayName, 293 + authorHandle = author.handle, 294 + authorLabels = author.labels, 295 + content = post.text, 296 + embed = when (post.embed) { 297 + is PostEmbedUnion.External -> { 298 + val c = (post.embed as PostEmbedUnion.External) 299 + PostViewEmbedUnion.ExternalView( 300 + ExternalView( 301 + ExternalViewExternal( 302 + uri = c.value.external.uri, 303 + title = c.value.external.title, 304 + description = c.value.external.description, 305 + thumb = cdnBlobURL( 306 + author.did, 307 + c.value.external.thumb, 308 + CDNImageSize.Thumb 309 + ) 310 + ) 311 + ) 312 + ) 313 + } 314 + 315 + is PostEmbedUnion.Images -> { 316 + val c = (post.embed as PostEmbedUnion.Images) 317 + PostViewEmbedUnion.ImagesView( 318 + ImagesView(c.value.images.map { 319 + ImagesViewImage( 320 + fullsize = cdnBlobURL( 321 + author.did, 322 + it.image, 323 + CDNImageSize.Full 324 + )!!, 325 + thumb = cdnBlobURL( 326 + author.did, 327 + it.image, 328 + CDNImageSize.Thumb 329 + )!!, 330 + alt = it.alt, 331 + aspectRatio = it.aspectRatio, 332 + ) 333 + }) 334 + ) 335 + } 336 + 337 + // Record need to be hydrated before being rendered! 338 + 339 + // is PostEmbedUnion.Record -> { 340 + // val c = (post.embed as PostEmbedUnion.Record).value 341 + // 342 + // PostViewEmbedUnion.RecordView( 343 + // RecordView(post.embed.value.record) 344 + // ) 345 + // } 346 + // 347 + // is PostEmbedUnion.RecordWithMedia -> PostViewEmbedUnion.RecordWithMediaView( 348 + // RecordWithMediaView( 349 + // post.embed.value.record, 350 + // post.embed.value.media 351 + // ) 352 + // ) 353 + // 354 + // is PostEmbedUnion.Unknown -> PostViewEmbedUnion.Unknown(post.embed.value) 355 + is PostEmbedUnion.Video -> { 356 + val c = (post.embed as PostEmbedUnion.Video).value 357 + PostViewEmbedUnion.VideoView( 358 + VideoView( 359 + playlist = cdnVideoPlaylist(author.did, c.video)!!, 360 + thumbnail = cdnVideoThumb(author.did, c.video), 361 + alt = c.alt, 362 + aspectRatio = c.aspectRatio, 363 + cid = parent.first 364 + ) 365 + ) 366 + } 367 + 368 + null -> null 369 + else -> null 370 + }, 371 + // TODO: fix embeds 372 + createdAt = post.createdAt.toStdlibInstant(), 373 + facets = post.facets, 374 + ) 375 + } 376 + 287 377 fun fromPost( 288 378 parent: Pair<Cid, AtUri>, 289 379 post: Post, ··· 530 620 531 621 sealed class Notification { 532 622 data class RawLike( 533 - val post: Post, 623 + val subject: StrongRef, 624 + val post: SkeetData, 534 625 val author: ProfileView, 535 626 val createdAt: Instant, 536 627 val new: Boolean ··· 538 629 Notification() 539 630 540 631 data class RawRepost( 541 - val post: Post, 632 + val subject: StrongRef, 633 + val post: SkeetData, 542 634 val author: ProfileView, 543 635 val createdAt: Instant, 544 636 val new: Boolean ··· 609 701 } 610 702 611 703 612 - enum class RepeatableNotification(val u: Unit) { 613 - Like(Unit), 614 - Repost(Unit) 704 + enum class RepeatableNotification() { 705 + Like, 706 + Repost 615 707 } 616 708 617 709 data class RepeatedNotification( 618 710 val kind: RepeatableNotification, 619 - val post: Post, 711 + val post: SkeetData, 620 712 var authors: List<RepeatedAuthor>, 621 713 var timestamp: Instant, 622 714 val new: Boolean,
+14 -5
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 249 249 } 250 250 } 251 251 252 - val posts = postsToFetch.chunked(25).fold(mapOf<AtUri, Post>()) { acc, chunk -> 252 + val posts = postsToFetch.chunked(25).fold(mapOf<AtUri, SkeetData>()) { acc, chunk -> 253 253 acc + bskyConn.getPosts(chunk).getOrThrow() 254 - .associate { it.uri to it.record.decodeAs<Post>() } 254 + .associate { 255 + it.uri to SkeetData.fromPost( 256 + (it.cid to it.uri), 257 + it.record.decodeAs<Post>(), 258 + it.author 259 + ) 260 + } 255 261 } 256 262 257 263 // we could bulk request posts here and avoid much of the network IO ··· 266 272 val l: Like = it.record.decodeAs() 267 273 val lp = posts[l.subject.uri]!! 268 274 269 - 270 275 repeatable += Notification.RawLike( 276 + l.subject, 271 277 lp, 272 278 it.author, 273 279 l.createdAt.toStdlibInstant(), ··· 314 320 val p: Repost = it.record.decodeAs() 315 321 val rpp = posts[p.subject.uri]!! 316 322 repeatable += Notification.RawRepost( 323 + p.subject, 317 324 rpp, 318 325 it.author, 319 326 p.createdAt.toStdlibInstant(), ··· 334 341 } 335 342 336 343 val processedRepeatable = 337 - mutableMapOf<RepeatableNotification, MutableMap<Post, RepeatedNotification>>() 344 + mutableMapOf<RepeatableNotification, MutableMap<SkeetData, RepeatedNotification>>() 338 345 339 346 val processRepeatable = 340 - { kind: RepeatableNotification, list: MutableMap<Post, RepeatedNotification>, post: Post, author: ProfileView, createdAt: Instant, new: Boolean -> 347 + { kind: RepeatableNotification, list: MutableMap<SkeetData, RepeatedNotification>, ref: StrongRef, post: SkeetData, author: ProfileView, createdAt: Instant, new: Boolean -> 341 348 if (list.contains(post)) { 342 349 val l = list[post]!! 343 350 l.authors += RepeatedAuthor(author, createdAt) ··· 371 378 processRepeatable( 372 379 RepeatableNotification.Like, 373 380 list, 381 + it.subject, 374 382 it.post, 375 383 it.author, 376 384 it.createdAt, ··· 386 394 processRepeatable( 387 395 RepeatableNotification.Repost, 388 396 list, 397 + it.subject, 389 398 it.post, 390 399 it.author, 391 400 it.createdAt,