A cheap attempt at a native Bluesky client for Android

NotificationsView: highlight new notification with elevation, mark notifications as read if there are some

+122 -29
+5 -4
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 133 133 sheetPeekHeight = 0.dp, 134 134 sheetDragHandle = {}, 135 135 sheetSwipeEnabled = false, 136 + sheetShadowElevation = 16.dp, 136 137 sheetContent = { 137 138 ComposeView( 138 139 context = LocalContext.current, ··· 473 474 LaunchedEffect(notificationsState.canScrollBackward) { 474 475 TabBarDestinations.NOTIFICATIONS.badgeValue?.intValue = 0 475 476 } 476 - 477 - 477 + 478 478 when (currentDestination) { 479 479 TabBarDestinations.TIMELINE -> ShowSkeets( 480 480 viewModel = timelineViewModel, ··· 489 489 TabBarDestinations.NOTIFICATIONS -> NotificationsView( 490 490 viewModel = timelineViewModel, 491 491 state = notificationsState, 492 - modifier = Modifier.padding(values), 492 + modifier = Modifier, 493 493 isScrollEnabled = isScrollEnabled, 494 - onReplyTap = onReplyTap 494 + onReplyTap = onReplyTap, 495 + scaffoldPadding = values 495 496 ) 496 497 } 497 498 }
+18 -3
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 2 2 3 3 import androidx.compose.foundation.layout.Arrangement 4 4 import androidx.compose.foundation.layout.Box 5 + import androidx.compose.foundation.layout.PaddingValues 5 6 import androidx.compose.foundation.layout.fillMaxSize 6 7 import androidx.compose.foundation.layout.fillMaxWidth 7 8 import androidx.compose.foundation.layout.padding ··· 10 11 import androidx.compose.foundation.lazy.LazyListState 11 12 import androidx.compose.foundation.lazy.items 12 13 import androidx.compose.material3.Card 14 + import androidx.compose.material3.CardDefaults 13 15 import androidx.compose.material3.CircularProgressIndicator 14 16 import androidx.compose.runtime.Composable 15 17 import androidx.compose.runtime.LaunchedEffect ··· 22 24 import industries.geesawra.monarch.datalayer.Notification 23 25 import industries.geesawra.monarch.datalayer.SkeetData 24 26 import industries.geesawra.monarch.datalayer.TimelineViewModel 27 + import kotlinx.coroutines.delay 25 28 import kotlin.time.ExperimentalTime 26 29 27 30 @ExperimentalTime ··· 32 35 modifier: Modifier = Modifier, 33 36 isScrollEnabled: Boolean, 34 37 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 38 + scaffoldPadding: PaddingValues 35 39 ) { 40 + LaunchedEffect(Unit) { 41 + if (viewModel.uiState.unreadNotificationsAmt != 0) { 42 + delay(500) 43 + viewModel.updateSeenNotifications() 44 + } 45 + } 46 + 36 47 LazyColumn( 37 48 state = state, 38 49 modifier = modifier 39 - .fillMaxSize() 40 - .padding(horizontal = 16.dp), 50 + .padding(scaffoldPadding), 41 51 userScrollEnabled = isScrollEnabled, 42 52 verticalArrangement = Arrangement.spacedBy(16.dp), 43 53 ) { ··· 45 55 items = viewModel.uiState.notifications, 46 56 key = { it.createdAt() } 47 57 ) { notif -> 48 - Card { 58 + Card( 59 + elevation = CardDefaults.cardElevation( 60 + defaultElevation = if (notif.new() && viewModel.uiState.unreadNotificationsAmt != 0) 8.dp else 0.dp, 61 + ), 62 + modifier = Modifier.padding(horizontal = 16.dp) 63 + ) { 49 64 RenderNotification( 50 65 viewModel = viewModel, 51 66 notification = notif,
+20
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 43 43 import app.bsky.labeler.GetServicesResponseViewUnion 44 44 import app.bsky.notification.ListNotificationsQueryParams 45 45 import app.bsky.notification.ListNotificationsResponse 46 + import app.bsky.notification.UpdateSeenRequest 46 47 import app.bsky.video.GetJobStatusQueryParams 47 48 import app.bsky.video.GetJobStatusResponse 48 49 import app.bsky.video.JobStatus ··· 893 894 return when (ret) { 894 895 is AtpResponse.Failure<*> -> Result.failure(Exception("Failed to fetch notifications: ${ret.error}")) 895 896 is AtpResponse.Success<ListNotificationsResponse> -> Result.success(ret.response) 897 + } 898 + } 899 + } 900 + 901 + suspend fun updateSeenNotifications(): Result<Unit> { 902 + return runCatching { 903 + create().onFailure { 904 + return Result.failure(LoginException(it.message)) 905 + } 906 + 907 + val ret = client!!.updateSeen( 908 + UpdateSeenRequest( 909 + seenAt = Clock.System.now().toDeprecatedInstant(), 910 + ) 911 + ) 912 + 913 + return when (ret) { 914 + is AtpResponse.Failure<*> -> Result.failure(Exception("Failed to update seen notifications: ${ret.error}")) 915 + is AtpResponse.Success<*> -> Result.success(Unit) 896 916 } 897 917 } 898 918 }
+43 -9
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 532 532 } 533 533 534 534 sealed class Notification { 535 - data class RawLike(val post: Post, val author: ProfileView, val createdAt: Instant) : 535 + data class RawLike( 536 + val post: Post, 537 + val author: ProfileView, 538 + val createdAt: Instant, 539 + val new: Boolean 540 + ) : 536 541 Notification() 537 542 538 - data class RawRepost(val post: Post, val author: ProfileView, val createdAt: Instant) : 543 + data class RawRepost( 544 + val post: Post, 545 + val author: ProfileView, 546 + val createdAt: Instant, 547 + val new: Boolean 548 + ) : 539 549 Notification() 540 550 541 - data class Like(val data: RepeatedNotification) : 551 + data class Like(val data: RepeatedNotification, val new: Boolean) : 542 552 Notification() 543 553 544 - data class Repost(val data: RepeatedNotification) : 554 + data class Repost(val data: RepeatedNotification, val new: Boolean) : 545 555 Notification() 546 556 547 557 data class Reply( 548 558 val parent: Pair<Cid, AtUri>, 549 559 val reply: Post, 550 560 val author: ProfileView, 551 - val createdAt: Instant 561 + val createdAt: Instant, 562 + val new: Boolean 552 563 ) : 553 564 Notification() 554 565 555 - data class Follow(val follow: ProfileView, val createdAt: Instant) : Notification() 566 + data class Follow(val follow: ProfileView, val createdAt: Instant, val new: Boolean) : 567 + Notification() 568 + 556 569 data class Mention( 557 570 val parent: Pair<Cid, AtUri>, 558 571 val mention: Post, 559 572 val author: ProfileView, 560 - val createdAt: Instant 573 + val createdAt: Instant, 574 + val new: Boolean 561 575 ) : 562 576 Notification() 563 577 ··· 565 579 val parent: Pair<Cid, AtUri>, 566 580 val quote: Post, 567 581 val author: ProfileView, 568 - val createdAt: Instant 582 + val createdAt: Instant, 583 + val new: Boolean 569 584 ) : 570 585 Notification() 571 586 ··· 581 596 is Repost -> this.data.timestamp 582 597 } 583 598 } 599 + 600 + fun new(): Boolean { 601 + return when (this) { 602 + is RawLike -> this.new 603 + is RawRepost -> this.new 604 + is Follow -> this.new 605 + is Like -> this.new 606 + is Mention -> this.new 607 + is Quote -> this.new 608 + is Reply -> this.new 609 + is Repost -> this.new 610 + } 611 + } 584 612 } 585 613 586 614 ··· 593 621 val kind: RepeatableNotification, 594 622 val post: Post, 595 623 var authors: List<RepeatedAuthor>, 596 - var timestamp: Instant 624 + var timestamp: Instant, 625 + val new: Boolean, 597 626 ) { 598 627 fun sorted(): RepeatedNotification { 599 628 return this.copy(kind, post, authors.sortedByDescending { it.timestamp }, timestamp) ··· 603 632 data class RepeatedAuthor( 604 633 val author: ProfileView, 605 634 val timestamp: Instant, 635 + ) 636 + 637 + data class ThreadPost( 638 + val post: SkeetData, 639 + val replies: List<ThreadPost> 606 640 )
+36 -13
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 236 236 when (it.reason) { 237 237 ListNotificationsReason.Follow -> { 238 238 val l: Follow = it.record.decodeAs() 239 - Notification.Follow(it.author, l.createdAt.toStdlibInstant()) 239 + Notification.Follow(it.author, l.createdAt.toStdlibInstant(), !it.isRead) 240 240 } 241 241 242 242 ListNotificationsReason.Like -> { ··· 247 247 repeatable += Notification.RawLike( 248 248 lp, 249 249 it.author, 250 - l.createdAt.toStdlibInstant() 250 + l.createdAt.toStdlibInstant(), 251 + !it.isRead 251 252 ) 252 253 253 254 null // repeatable, will be processed later ··· 259 260 Pair(it.cid, it.uri), 260 261 p, 261 262 it.author, 262 - p.createdAt.toStdlibInstant() 263 + p.createdAt.toStdlibInstant(), 264 + !it.isRead 263 265 ) 264 266 } 265 267 ··· 269 271 Pair(it.cid, it.uri), 270 272 p, 271 273 it.author, 272 - p.createdAt.toStdlibInstant() 274 + p.createdAt.toStdlibInstant(), 275 + !it.isRead 273 276 ) 274 277 } 275 278 ··· 279 282 Pair(it.cid, it.uri), 280 283 p, 281 284 it.author, 282 - p.createdAt.toStdlibInstant() 285 + p.createdAt.toStdlibInstant(), 286 + !it.isRead 283 287 ) 284 288 } 285 289 ··· 289 293 repeatable += Notification.RawRepost( 290 294 rpp, 291 295 it.author, 292 - p.createdAt.toStdlibInstant() 296 + p.createdAt.toStdlibInstant(), 297 + !it.isRead 293 298 ) 294 299 295 300 null ··· 309 314 mutableMapOf<RepeatableNotification, MutableMap<Post, RepeatedNotification>>() 310 315 311 316 val processRepeatable = 312 - { kind: RepeatableNotification, list: MutableMap<Post, RepeatedNotification>, post: Post, author: ProfileView, createdAt: Instant -> 317 + { kind: RepeatableNotification, list: MutableMap<Post, RepeatedNotification>, post: Post, author: ProfileView, createdAt: Instant, new: Boolean -> 313 318 if (list.contains(post)) { 314 319 val l = list[post]!! 315 320 l.authors += RepeatedAuthor(author, createdAt) ··· 327 332 ) 328 333 ), 329 334 post = post, 330 - timestamp = createdAt 335 + timestamp = createdAt, 336 + new = new, 331 337 ) 332 338 } 333 339 } ··· 344 350 list, 345 351 it.post, 346 352 it.author, 347 - it.createdAt 353 + it.createdAt, 354 + it.new, 348 355 ) 349 356 } 350 357 ··· 358 365 list, 359 366 it.post, 360 367 it.author, 361 - it.createdAt 368 + it.createdAt, 369 + it.new 362 370 ) 363 371 } 364 372 ··· 375 383 r.kind, 376 384 r.post, 377 385 r.authors.sortedByDescending { it.timestamp }, 378 - r.timestamp 379 - ) 386 + r.timestamp, 387 + ), 388 + new = r.new 380 389 ) 381 390 } 382 391 } ··· 389 398 r.post, 390 399 r.authors.sortedByDescending { it.timestamp }, 391 400 r.timestamp 392 - ) 401 + ), 402 + new = r.new 393 403 ) 394 404 } 395 405 } ··· 405 415 ) 406 416 407 417 then() 418 + } 419 + } 420 + 421 + fun updateSeenNotifications() { 422 + viewModelScope.launch { 423 + bskyConn.updateSeenNotifications().onFailure { 424 + uiState = when (it) { 425 + is LoginException -> uiState.copy(loginError = it.message) 426 + else -> uiState.copy(error = it.message) 427 + } 428 + }.onSuccess { 429 + uiState = uiState.copy(unreadNotificationsAmt = 0) 430 + } 408 431 } 409 432 } 410 433