A cheap attempt at a native Bluesky client for Android

Notifications: Group reposts and improve UI

Group repost notifications by post, similar to how likes are handled. This combines multiple reposts of the same skeet into a single notification entry.

This change also introduces the following improvements to the notifications view:
* Renamed `LikeRowView` to the more generic `LikeRepostRowView` to accommodate both likes and reposts.
* Increased the spacing between notification items for better readability.
* Removed the card elevation around individual notifications for a flatter design.

+154 -57
+28 -9
app/src/main/java/industries/geesawra/monarch/LikeRowView.kt app/src/main/java/industries/geesawra/monarch/LikeRepostRowView.kt
··· 23 23 import coil3.compose.AsyncImage 24 24 import coil3.request.ImageRequest 25 25 import coil3.request.crossfade 26 + import industries.geesawra.monarch.datalayer.RepeatableNotification 26 27 import industries.geesawra.monarch.datalayer.RepeatedNotification 27 28 import nl.jacobras.humanreadable.HumanReadable 28 29 import kotlin.time.ExperimentalTime 29 30 30 31 @OptIn(ExperimentalTime::class) 31 32 @Composable 32 - fun LikeRowView( 33 + fun LikeRepostRowView( 33 34 modifier: Modifier = Modifier, 34 - likeData: RepeatedNotification 35 + data: RepeatedNotification 36 + 35 37 ) { 36 38 val minSize = 55.dp 37 39 ··· 50 52 ) { 51 53 AsyncImage( 52 54 model = ImageRequest.Builder(LocalContext.current) 53 - .data(likeData.authors.first().author.avatar?.uri) 55 + .data(data.authors.first().author.avatar?.uri) 54 56 .crossfade(true) 55 57 .build(), 56 58 contentDescription = "Avatar", ··· 59 61 .clip(CircleShape) 60 62 ) 61 63 62 - val authors = likeData.authors 64 + val authors = data.authors 63 65 val firstAuthorName = 64 66 authors.first().author.displayName ?: authors.first().author.handle 65 67 val remainingCount = authors.size - 1 66 68 val text = when { 67 - remainingCount > 1 -> "$firstAuthorName and $remainingCount others liked this" 68 - remainingCount == 1 -> "$firstAuthorName and 1 other liked this" 69 - else -> "$firstAuthorName liked this" 69 + remainingCount > 1 -> "$firstAuthorName and $remainingCount others ${ 70 + when (data.kind) { 71 + RepeatableNotification.Like -> "liked" 72 + RepeatableNotification.Repost -> "reposted" 73 + } 74 + } this" 75 + 76 + remainingCount == 1 -> "$firstAuthorName and 1 other ${ 77 + when (data.kind) { 78 + RepeatableNotification.Like -> "liked" 79 + RepeatableNotification.Repost -> "reposted" 80 + } 81 + } this" 82 + 83 + else -> "$firstAuthorName ${ 84 + when (data.kind) { 85 + RepeatableNotification.Like -> "liked" 86 + RepeatableNotification.Repost -> "reposted" 87 + } 88 + } this" 70 89 } 71 90 72 91 Column( ··· 82 101 ) 83 102 84 103 Text( 85 - text = HumanReadable.timeAgo(likeData.timestamp), 104 + text = HumanReadable.timeAgo(data.timestamp), 86 105 color = MaterialTheme.colorScheme.onSurfaceVariant, 87 106 style = MaterialTheme.typography.bodySmall, 88 107 textAlign = TextAlign.End, ··· 91 110 92 111 Text( 93 112 modifier = Modifier.fillMaxWidth(), 94 - text = likeData.post.text, 113 + text = data.post.text, 95 114 color = MaterialTheme.colorScheme.secondary, 96 115 style = MaterialTheme.typography.bodySmall, 97 116 )
+15 -14
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 8 8 import androidx.compose.foundation.layout.width 9 9 import androidx.compose.foundation.lazy.LazyColumn 10 10 import androidx.compose.foundation.lazy.LazyListState 11 + import androidx.compose.material3.CardDefaults 11 12 import androidx.compose.material3.CircularProgressIndicator 12 13 import androidx.compose.material3.ElevatedCard 13 14 import androidx.compose.runtime.Composable ··· 38 39 .fillMaxSize() 39 40 .padding(horizontal = 16.dp), 40 41 userScrollEnabled = isScrollEnabled, 41 - verticalArrangement = Arrangement.spacedBy(8.dp), 42 + verticalArrangement = Arrangement.spacedBy(16.dp), 42 43 ) { 43 - viewModel.uiState.notifications.list.forEach { notif -> 44 + viewModel.uiState.notifications.forEach { notif -> 44 45 item(notif.createdAt()) { 45 - ElevatedCard { 46 + ElevatedCard( 47 + elevation = CardDefaults.elevatedCardElevation( 48 + defaultElevation = 0.dp 49 + ) 50 + ) { 46 51 RenderNotification( 47 52 viewModel = viewModel, 48 53 notification = notif, ··· 52 57 } 53 58 } 54 59 55 - if (viewModel.uiState.isFetchingMoreNotifications && viewModel.uiState.notifications.list.isNotEmpty()) { 60 + if (viewModel.uiState.isFetchingMoreNotifications && viewModel.uiState.notifications.isNotEmpty()) { 56 61 item { 57 62 Box( 58 63 modifier = Modifier ··· 88 93 } 89 94 90 95 LaunchedEffect(endOfListReached) { 91 - if (endOfListReached && viewModel.uiState.notifications.list.isNotEmpty()) { 96 + if (endOfListReached && viewModel.uiState.notifications.isNotEmpty()) { 92 97 viewModel.fetchNotifications() 93 98 } 94 99 } ··· 111 116 nested = true 112 117 ) 113 118 114 - is Notification.Like -> LikeRowView( 115 - likeData = notification.data, 119 + is Notification.Like -> LikeRepostRowView( 120 + data = notification.data, 116 121 ) 117 122 118 123 is Notification.Mention -> SkeetView( ··· 145 150 onReplyTap = onReplyTap, 146 151 ) 147 152 148 - is Notification.Repost -> SkeetView( 149 - skeet = SkeetData( 150 - authorName = (notification.author.displayName 151 - ?: notification.author.handle).toString() + " reposted your post", 152 - authorAvatarURL = notification.author.avatar.toString() 153 - ), 154 - nested = true 153 + is Notification.Repost -> LikeRepostRowView( 154 + data = notification.data, 155 155 ) 156 + 156 157 157 158 else -> {} 158 159 }
+26 -5
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 271 271 ) 272 272 } 273 273 274 + fun fromPost( 275 + parent: Pair<Cid, AtUri>, 276 + post: Post, 277 + author: ProfileView, 278 + embed: PostViewEmbedUnion? 279 + ): SkeetData { 280 + return SkeetData( 281 + cid = parent.first, 282 + uri = parent.second, 283 + authorAvatarURL = author.avatar?.uri, 284 + authorName = author.displayName, 285 + authorHandle = author.handle, 286 + authorLabels = author.labels, 287 + content = post.text, 288 + embed = embed, 289 + createdAt = post.createdAt.toStdlibInstant(), 290 + facets = post.facets, 291 + ) 292 + } 293 + 274 294 275 295 fun fromRecordView(post: RecordViewRecord): SkeetData { 276 296 val content: Post = (post.value.decodeAs()) ··· 487 507 data class RawLike(val post: Post, val author: ProfileView, val createdAt: Instant) : 488 508 Notification() 489 509 510 + data class RawRepost(val post: Post, val author: ProfileView, val createdAt: Instant) : 511 + Notification() 512 + 490 513 data class Like(val data: RepeatedNotification) : 491 514 Notification() 492 515 493 - data class Repost(val repost: Post, val author: ProfileView, val createdAt: Instant) : 516 + data class Repost(val data: RepeatedNotification) : 494 517 Notification() 495 518 496 519 data class Reply( ··· 521 544 fun createdAt(): Instant { 522 545 return when (this) { 523 546 is RawLike -> this.createdAt 547 + is RawRepost -> this.createdAt 524 548 is Follow -> this.createdAt 525 549 is Like -> this.data.timestamp 526 550 is Mention -> this.createdAt 527 551 is Quote -> this.createdAt 528 552 is Reply -> this.createdAt 529 - is Repost -> this.createdAt 553 + is Repost -> this.data.timestamp 530 554 } 531 555 } 532 556 } 533 557 534 - data class Notifications( 535 - var list: List<Notification> = listOf() 536 - ) 537 558 538 559 enum class RepeatableNotification(val u: Unit) { 539 560 Like(Unit),
+85 -29
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 9 9 import androidx.compose.ui.util.fastForEach 10 10 import androidx.lifecycle.ViewModel 11 11 import androidx.lifecycle.viewModelScope 12 + import app.bsky.actor.ProfileView 12 13 import app.bsky.actor.ProfileViewDetailed 13 14 import app.bsky.feed.GeneratorView 14 15 import app.bsky.feed.Like 15 16 import app.bsky.feed.Post 17 + import app.bsky.feed.PostEmbedUnion 16 18 import app.bsky.feed.PostReplyRef 17 19 import app.bsky.feed.Repost 18 20 import app.bsky.graph.Follow ··· 32 34 import sh.christian.ozone.api.model.JsonContent 33 35 import kotlin.coroutines.cancellation.CancellationException 34 36 import kotlin.time.ExperimentalTime 37 + import kotlin.time.Instant 35 38 36 39 37 40 data class TimelineUiState( ··· 40 43 val feedAvatar: String? = null, 41 44 val feeds: List<GeneratorView> = listOf(), 42 45 val skeets: List<SkeetData> = listOf(), 43 - val notifications: Notifications = Notifications(list = listOf()), 46 + val notifications: List<Notification> = listOf(), 44 47 val isFetchingMoreTimeline: Boolean = false, 45 48 val isFetchingMoreNotifications: Boolean = false, 46 49 val authenticated: Boolean = false, ··· 197 200 val repeatable = mutableListOf<Notification>() 198 201 199 202 // we could bulk request posts here and avoid much of the network IO 200 - var notifs = Notifications(list = rawNotifs.notifications.mapNotNull { 203 + var notifs = rawNotifs.notifications.mapNotNull { 201 204 when (it.reason) { 202 205 ListNotificationsReason.Follow -> { 203 206 val l: Follow = it.record.decodeAs() ··· 249 252 250 253 ListNotificationsReason.Repost -> { 251 254 val p: Repost = it.record.decodeAs() 252 - val pp = fetchRecord(p.subject.uri).getOrThrow() 253 - Notification.Repost(pp.decodeAs(), it.author, p.createdAt.toStdlibInstant()) 255 + val rpp: Post = fetchRecord(p.subject.uri).getOrThrow().decodeAs() 256 + 257 + val pp = when (rpp.embed) { 258 + is PostEmbedUnion.Record -> { 259 + fetchRecord((rpp.embed as PostEmbedUnion.Record).value.record.uri).getOrThrow() 260 + .decodeAs() 261 + 262 + } 263 + // is PostEmbedUnion.RecordWithMedia -> TODO() 264 + else -> rpp 265 + } 266 + 267 + repeatable += Notification.RawRepost( 268 + pp, 269 + it.author, 270 + p.createdAt.toStdlibInstant() 271 + ) 272 + 273 + null // repeatable, will be processed later 254 274 } 255 275 256 276 else -> { 257 277 null 258 278 } 259 279 } 260 - }) 280 + }.toMutableList() 261 281 262 282 if (fresh) { 263 - uiState = uiState.copy(notifications = Notifications(list = listOf())) 283 + uiState = uiState.copy(notifications = listOf()) 264 284 } 265 285 266 286 val processedRepeatable = 267 287 mutableMapOf<RepeatableNotification, MutableMap<Post, RepeatedNotification>>() 268 288 289 + val processRepeatable = 290 + { kind: RepeatableNotification, list: MutableMap<Post, RepeatedNotification>, post: Post, author: ProfileView, createdAt: Instant -> 291 + if (list.contains(post)) { 292 + val l = list[post]!! 293 + l.authors += RepeatedAuthor(author, createdAt) 294 + if (createdAt > l.timestamp) { 295 + l.timestamp = createdAt 296 + } 297 + list[post] = l 298 + } else { 299 + list[post] = RepeatedNotification( 300 + kind = kind, 301 + authors = listOf( 302 + RepeatedAuthor( 303 + author, 304 + createdAt 305 + ) 306 + ), 307 + post = post, 308 + timestamp = createdAt 309 + ) 310 + } 311 + } 312 + 269 313 repeatable.fastForEach { 270 314 when (it) { 271 315 is Notification.RawLike -> { ··· 273 317 mutableMapOf() 274 318 } 275 319 276 - if (list.contains(it.post)) { 277 - val l = list[it.post]!! 278 - l.authors += RepeatedAuthor(it.author, it.createdAt) 279 - if (it.createdAt > l.timestamp) { 280 - l.timestamp = it.createdAt 281 - } 282 - list[it.post] = l 283 - } else { 284 - list[it.post] = RepeatedNotification( 285 - kind = RepeatableNotification.Like, 286 - authors = listOf( 287 - RepeatedAuthor( 288 - it.author, 289 - it.createdAt 290 - ) 291 - ), 292 - post = it.post, 293 - timestamp = it.createdAt 294 - ) 320 + processRepeatable( 321 + RepeatableNotification.Like, 322 + list, 323 + it.post, 324 + it.author, 325 + it.createdAt 326 + ) 327 + } 328 + 329 + is Notification.RawRepost -> { 330 + val list = processedRepeatable.getOrPut(RepeatableNotification.Repost) { 331 + mutableMapOf() 295 332 } 333 + 334 + processRepeatable( 335 + RepeatableNotification.Repost, 336 + list, 337 + it.post, 338 + it.author, 339 + it.createdAt 340 + ) 296 341 } 297 342 298 343 else -> null ··· 303 348 when (a) { 304 349 RepeatableNotification.Like -> { 305 350 n.forEach { _, r -> 306 - notifs.list += Notification.Like( 351 + notifs += Notification.Like( 307 352 data = r.copy( 308 353 r.kind, 309 354 r.post, ··· 314 359 } 315 360 } 316 361 317 - RepeatableNotification.Repost -> {} 362 + RepeatableNotification.Repost -> { 363 + n.forEach { _, r -> 364 + notifs += Notification.Like( 365 + data = r.copy( 366 + r.kind, 367 + r.post, 368 + r.authors.sortedByDescending { it.timestamp }, 369 + r.timestamp 370 + ) 371 + ) 372 + } 373 + } 318 374 } 319 375 } 320 376 321 - notifs.list = notifs.list.sortedByDescending { it.createdAt() } 377 + notifs = notifs.sortedByDescending { it.createdAt() }.toMutableList() 322 378 323 379 uiState = uiState.copy( 324 - notifications = Notifications(list = uiState.notifications.list + notifs.list), 380 + notifications = uiState.notifications + notifs, 325 381 notificationsCursor = rawNotifs.cursor, 326 382 isFetchingMoreNotifications = false, 327 383 )