A cheap attempt at a native Bluesky client for Android

*: kinda sorta notification grouping and display

+340 -33
+3
.idea/deploymentTargetSelector.xml
··· 13 13 </DropdownSelection> 14 14 <DialogSelection /> 15 15 </SelectionState> 16 + <SelectionState runConfigName="LikeRowViewPreview"> 17 + <option name="selectionMode" value="DROPDOWN" /> 18 + </SelectionState> 16 19 </selectionStates> 17 20 </component> 18 21 </project>
+108
app/src/main/java/industries/geesawra/monarch/LikeRowView.kt
··· 1 + package industries.geesawra.monarch 2 + 3 + import androidx.compose.foundation.layout.Column 4 + import androidx.compose.foundation.layout.Row 5 + import androidx.compose.foundation.layout.fillMaxWidth 6 + import androidx.compose.foundation.layout.padding 7 + import androidx.compose.foundation.layout.size 8 + import androidx.compose.foundation.shape.CircleShape 9 + import androidx.compose.material3.HorizontalDivider 10 + import androidx.compose.material3.MaterialTheme 11 + import androidx.compose.material3.Surface 12 + import androidx.compose.material3.Text 13 + import androidx.compose.runtime.Composable 14 + import androidx.compose.ui.Alignment 15 + import androidx.compose.ui.Modifier 16 + import androidx.compose.ui.draw.clip 17 + import androidx.compose.ui.graphics.Color 18 + import androidx.compose.ui.platform.LocalContext 19 + import androidx.compose.ui.text.font.FontWeight 20 + import androidx.compose.ui.text.style.TextAlign 21 + import androidx.compose.ui.unit.dp 22 + import coil3.compose.AsyncImage 23 + import coil3.request.ImageRequest 24 + import coil3.request.crossfade 25 + import industries.geesawra.monarch.datalayer.RepeatedNotification 26 + import nl.jacobras.humanreadable.HumanReadable 27 + import kotlin.time.ExperimentalTime 28 + 29 + @OptIn(ExperimentalTime::class) 30 + @Composable 31 + fun LikeRowView( 32 + modifier: Modifier = Modifier, 33 + color: Color = MaterialTheme.colorScheme.surface, 34 + likeData: RepeatedNotification 35 + ) { 36 + val minSize = 55.dp 37 + 38 + Surface( 39 + color = color, 40 + modifier = modifier 41 + .padding(horizontal = 16.dp, vertical = 8.dp) 42 + .fillMaxWidth() 43 + 44 + ) { 45 + Column { 46 + Row( 47 + verticalAlignment = Alignment.CenterVertically, 48 + ) { 49 + AsyncImage( 50 + model = ImageRequest.Builder(LocalContext.current) 51 + .data(likeData.authors.first().author.avatar?.uri) 52 + .crossfade(true) 53 + .build(), 54 + contentDescription = "Avatar", 55 + modifier = Modifier 56 + .size(minSize) 57 + .clip(CircleShape) 58 + ) 59 + 60 + val authors = likeData.authors 61 + val firstAuthorName = 62 + authors.first().author.displayName ?: authors.first().author.handle 63 + val remainingCount = authors.size - 1 64 + val text = when { 65 + remainingCount > 1 -> "$firstAuthorName and $remainingCount others liked this" 66 + remainingCount == 1 -> "$firstAuthorName and 1 other liked this" 67 + else -> "$firstAuthorName liked this" 68 + } 69 + 70 + Column( 71 + modifier = Modifier 72 + .padding(start = 16.dp) 73 + .fillMaxWidth() 74 + ) { 75 + Text( 76 + modifier = Modifier.fillMaxWidth(), 77 + text = text, 78 + style = MaterialTheme.typography.bodyMedium, 79 + fontWeight = FontWeight.Bold, 80 + maxLines = 1 81 + ) 82 + 83 + Text( 84 + text = HumanReadable.timeAgo(likeData.timestamp), 85 + color = MaterialTheme.colorScheme.onSurfaceVariant, 86 + style = MaterialTheme.typography.bodySmall, 87 + textAlign = TextAlign.End, 88 + modifier = Modifier.fillMaxWidth() 89 + ) 90 + 91 + Text( 92 + modifier = Modifier.fillMaxWidth(), 93 + text = likeData.post.text, 94 + color = MaterialTheme.colorScheme.secondary, 95 + style = MaterialTheme.typography.bodySmall, 96 + ) 97 + } 98 + } 99 + 100 + HorizontalDivider( 101 + color = MaterialTheme.colorScheme.outlineVariant 102 + ) 103 + } 104 + 105 + 106 + } 107 + 108 + }
+9 -11
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 4 4 import androidx.compose.foundation.layout.Box 5 5 import androidx.compose.foundation.layout.fillMaxSize 6 6 import androidx.compose.foundation.layout.fillMaxWidth 7 + import androidx.compose.foundation.layout.height 7 8 import androidx.compose.foundation.layout.padding 8 9 import androidx.compose.foundation.layout.width 9 10 import androidx.compose.foundation.lazy.LazyColumn ··· 37 38 userScrollEnabled = isScrollEnabled, 38 39 verticalArrangement = Arrangement.spacedBy(8.dp), 39 40 ) { 40 - viewModel.uiState.notifications.forEach { notif -> 41 + viewModel.uiState.notifications.list.forEach { notif -> 41 42 item() { 42 43 RenderNotification( 43 44 viewModel = viewModel, ··· 47 48 } 48 49 } 49 50 50 - if (viewModel.uiState.isFetchingMoreNotifications && viewModel.uiState.notifications.isNotEmpty()) { 51 + if (viewModel.uiState.isFetchingMoreNotifications && viewModel.uiState.notifications.list.isNotEmpty()) { 51 52 item { 52 53 Box( 53 54 modifier = Modifier ··· 83 84 } 84 85 85 86 LaunchedEffect(endOfListReached) { 86 - if (endOfListReached && viewModel.uiState.notifications.isNotEmpty()) { 87 + if (endOfListReached && viewModel.uiState.notifications.list.isNotEmpty()) { 87 88 viewModel.fetchNotifications() 88 89 } 89 90 } ··· 106 107 nested = true 107 108 ) 108 109 109 - is Notification.Like -> SkeetView( 110 - skeet = SkeetData( 111 - authorName = (notification.author.displayName 112 - ?: notification.author.handle).toString() + " liked your post", 113 - authorAvatarURL = notification.author.avatar.toString() 114 - ), 115 - nested = true 110 + is Notification.Like -> LikeRowView( 111 + likeData = notification.data, 112 + modifier = Modifier.height(120.dp), 116 113 ) 117 - 118 114 119 115 is Notification.Mention -> SkeetView( 120 116 viewModel = viewModel, ··· 154 150 ), 155 151 nested = true 156 152 ) 153 + 154 + else -> {} 157 155 } 158 156 }
-1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 139 139 inThread = inThread 140 140 ) 141 141 } 142 - 143 142 } 144 143 145 144 }
+22
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 24 24 import app.bsky.feed.GetFeedGeneratorsQueryParams 25 25 import app.bsky.feed.GetFeedQueryParams 26 26 import app.bsky.feed.GetFeedResponse 27 + import app.bsky.feed.GetPostsQueryParams 28 + import app.bsky.feed.GetPostsResponse 27 29 import app.bsky.feed.GetTimelineQueryParams 28 30 import app.bsky.feed.GetTimelineResponse 29 31 import app.bsky.feed.Like 30 32 import app.bsky.feed.Post 31 33 import app.bsky.feed.PostEmbedUnion 32 34 import app.bsky.feed.PostReplyRef 35 + import app.bsky.feed.PostView 33 36 import app.bsky.feed.Repost 34 37 import app.bsky.labeler.GetServicesQueryParams 35 38 import app.bsky.labeler.GetServicesResponse ··· 854 857 return when (ret) { 855 858 is AtpResponse.Failure<*> -> Result.failure(Exception("Failed to fetch notifications: ${ret.error}")) 856 859 is AtpResponse.Success<ListNotificationsResponse> -> Result.success(ret.response) 860 + } 861 + } 862 + } 863 + 864 + suspend fun getPosts(uri: List<AtUri>): Result<List<PostView>> { 865 + return runCatching { 866 + create().onFailure { 867 + return Result.failure(LoginException(it.message)) 868 + } 869 + 870 + val ret = client!!.getPosts( 871 + GetPostsQueryParams( 872 + uris = uri, 873 + ) 874 + ) 875 + 876 + return when (ret) { 877 + is AtpResponse.Failure<*> -> Result.failure(Exception("Failed to fetch posts: ${ret.error}")) 878 + is AtpResponse.Success<GetPostsResponse> -> Result.success(ret.response.posts) 857 879 } 858 880 } 859 881 }
+94 -6
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 381 381 } 382 382 383 383 sealed class Notification { 384 - data class Like(val post: Post, val author: ProfileView) : Notification() 385 - data class Repost(val repost: Post, val author: ProfileView) : Notification() 386 - data class Reply(val parent: Pair<Cid, AtUri>, val reply: Post, val author: ProfileView) : 384 + data class RawLike(val post: Post, val author: ProfileView, val createdAt: Instant) : 385 + Notification() 386 + 387 + data class Like(val data: RepeatedNotification) : 387 388 Notification() 388 389 389 - data class Follow(val follow: ProfileView) : Notification() 390 - data class Mention(val parent: Pair<Cid, AtUri>, val mention: Post, val author: ProfileView) : 390 + data class Repost(val repost: Post, val author: ProfileView, val createdAt: Instant) : 391 391 Notification() 392 392 393 - data class Quote(val parent: Pair<Cid, AtUri>, val quote: Post, val author: ProfileView) : 393 + data class Reply( 394 + val parent: Pair<Cid, AtUri>, 395 + val reply: Post, 396 + val author: ProfileView, 397 + val createdAt: Instant 398 + ) : 394 399 Notification() 400 + 401 + data class Follow(val follow: ProfileView, val createdAt: Instant) : Notification() 402 + data class Mention( 403 + val parent: Pair<Cid, AtUri>, 404 + val mention: Post, 405 + val author: ProfileView, 406 + val createdAt: Instant 407 + ) : 408 + Notification() 409 + 410 + data class Quote( 411 + val parent: Pair<Cid, AtUri>, 412 + val quote: Post, 413 + val author: ProfileView, 414 + val createdAt: Instant 415 + ) : 416 + Notification() 417 + 418 + fun createdAt(): Instant { 419 + return when (this) { 420 + is RawLike -> this.createdAt 421 + is Follow -> this.createdAt 422 + is Like -> this.data.timestamp 423 + is Mention -> this.createdAt 424 + is Quote -> this.createdAt 425 + is Reply -> this.createdAt 426 + is Repost -> this.createdAt 427 + } 428 + } 395 429 } 430 + 431 + data class Notifications( 432 + var list: List<Notification> = listOf() 433 + ) { 434 + // fun likes() { 435 + // val likes = mutableMapOf<Post, RepeatedNotification>() 436 + // list.forEach { 437 + // when (it) { 438 + // is Notification.Like -> { 439 + // if (likes.contains(it.post)) { 440 + // val l = likes[it.post]!! 441 + // l.authors += RepeatedAuthor(it.author, it.createdAt) 442 + // if (it.createdAt > l.timestamp) { 443 + // l.timestamp = it.createdAt 444 + // } 445 + // likes[it.post] = l 446 + // } else { 447 + // likes[it.post] = RepeatedNotification( 448 + // kind = RepeatableNotification.Like, 449 + // authors = listOf(RepeatedAuthor(it.author, it.createdAt)), 450 + // timestamp = it.createdAt 451 + // ) 452 + // } 453 + // } 454 + // 455 + // else -> null 456 + // } 457 + // } 458 + // 459 + // val asd = likes.entries.sortedByDescending { it.value.timestamp }.associate { it.toPair() } 460 + // Log.d("likes", likes.toString()) 461 + // } 462 + } 463 + 464 + enum class RepeatableNotification(val u: Unit) { 465 + Like(Unit), 466 + Repost(Unit) 467 + } 468 + 469 + data class RepeatedNotification( 470 + val kind: RepeatableNotification, 471 + val post: Post, 472 + var authors: List<RepeatedAuthor>, 473 + var timestamp: Instant 474 + ) { 475 + fun sorted(): RepeatedNotification { 476 + return this.copy(kind, post, authors.sortedByDescending { it.timestamp }, timestamp) 477 + } 478 + } 479 + 480 + data class RepeatedAuthor( 481 + val author: ProfileView, 482 + val timestamp: Instant, 483 + )
+104 -15
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 6 6 import androidx.compose.runtime.getValue 7 7 import androidx.compose.runtime.mutableStateOf 8 8 import androidx.compose.runtime.setValue 9 + import androidx.compose.ui.util.fastForEach 9 10 import androidx.lifecycle.ViewModel 10 11 import androidx.lifecycle.viewModelScope 11 12 import app.bsky.actor.ProfileViewDetailed 12 13 import app.bsky.feed.GeneratorView 13 14 import app.bsky.feed.Like 15 + import app.bsky.feed.Post 14 16 import app.bsky.feed.PostReplyRef 17 + import app.bsky.feed.Repost 18 + import app.bsky.graph.Follow 15 19 import app.bsky.notification.ListNotificationsReason 16 20 import com.atproto.repo.StrongRef 17 21 import dagger.assisted.Assisted ··· 20 24 import dagger.hilt.android.lifecycle.HiltViewModel 21 25 import kotlinx.coroutines.Job 22 26 import kotlinx.coroutines.launch 27 + import kotlinx.datetime.toStdlibInstant 23 28 import sh.christian.ozone.api.AtUri 24 29 import sh.christian.ozone.api.Cid 25 30 import sh.christian.ozone.api.Did ··· 35 40 val feedAvatar: String? = null, 36 41 val feeds: List<GeneratorView> = listOf(), 37 42 val skeets: List<SkeetData> = listOf(), 38 - val notifications: List<Notification> = listOf(), 43 + val notifications: Notifications = Notifications(list = listOf()), 39 44 val isFetchingMoreTimeline: Boolean = false, 40 45 val isFetchingMoreNotifications: Boolean = false, 41 46 val authenticated: Boolean = false, ··· 189 194 ) 190 195 }.getOrThrow() 191 196 192 - val notifs: List<Notification> = rawNotifs.notifications.mapNotNull { 197 + val repeatable = mutableListOf<Notification>() 198 + 199 + // we could bulk request posts here and avoid much of the network IO 200 + var notifs = Notifications(list = rawNotifs.notifications.mapNotNull { 193 201 when (it.reason) { 194 202 ListNotificationsReason.Follow -> { 195 - Notification.Follow(it.author) 203 + val l: Follow = it.record.decodeAs() 204 + Notification.Follow(it.author, l.createdAt.toStdlibInstant()) 196 205 } 197 206 198 207 ListNotificationsReason.Like -> { 199 208 val l: Like = it.record.decodeAs() 200 209 val lp = fetchRecord(l.subject.uri).getOrThrow() 201 - Notification.Like(lp.decodeAs(), it.author) 210 + 211 + repeatable += Notification.RawLike( 212 + lp.decodeAs(), 213 + it.author, 214 + l.createdAt.toStdlibInstant() 215 + ) 216 + 217 + null // repeatable, will be processed later 202 218 } 203 219 204 220 ListNotificationsReason.Mention -> { 205 - val p: app.bsky.feed.Post = it.record.decodeAs() 206 - Notification.Mention(Pair(it.cid, it.uri), p, it.author) 221 + val p: Post = it.record.decodeAs() 222 + Notification.Mention( 223 + Pair(it.cid, it.uri), 224 + p, 225 + it.author, 226 + p.createdAt.toStdlibInstant() 227 + ) 207 228 } 208 229 209 230 ListNotificationsReason.Quote -> { 210 - val p: app.bsky.feed.Post = it.record.decodeAs() 211 - Notification.Quote(Pair(it.cid, it.uri), p, it.author) 231 + val p: Post = it.record.decodeAs() 232 + Notification.Quote( 233 + Pair(it.cid, it.uri), 234 + p, 235 + it.author, 236 + p.createdAt.toStdlibInstant() 237 + ) 212 238 } 213 239 214 240 ListNotificationsReason.Reply -> { 215 - val p: app.bsky.feed.Post = it.record.decodeAs() 216 - Notification.Reply(Pair(it.cid, it.uri), p, it.author) 241 + val p: Post = it.record.decodeAs() 242 + Notification.Reply( 243 + Pair(it.cid, it.uri), 244 + p, 245 + it.author, 246 + p.createdAt.toStdlibInstant() 247 + ) 217 248 } 218 249 219 250 ListNotificationsReason.Repost -> { 220 - val p: app.bsky.feed.Repost = it.record.decodeAs() 251 + val p: Repost = it.record.decodeAs() 221 252 val pp = fetchRecord(p.subject.uri).getOrThrow() 222 - Notification.Repost(pp.decodeAs(), it.author) 253 + Notification.Repost(pp.decodeAs(), it.author, p.createdAt.toStdlibInstant()) 223 254 } 224 255 225 256 else -> { 226 257 null 227 258 } 228 259 } 260 + }) 261 + 262 + if (fresh) { 263 + uiState = uiState.copy(notifications = Notifications(list = listOf())) 229 264 } 230 265 231 - if (fresh) { 232 - uiState = uiState.copy(notifications = listOf()) 266 + val processedRepeatable = 267 + mutableMapOf<RepeatableNotification, MutableMap<Post, RepeatedNotification>>() 268 + 269 + repeatable.fastForEach { 270 + when (it) { 271 + is Notification.RawLike -> { 272 + val list = processedRepeatable.getOrPut(RepeatableNotification.Like) { 273 + mutableMapOf() 274 + } 275 + 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 + ) 295 + } 296 + } 297 + 298 + else -> null 299 + } 300 + } 301 + 302 + processedRepeatable.forEach { a, n -> 303 + when (a) { 304 + RepeatableNotification.Like -> { 305 + n.forEach { _, r -> 306 + notifs.list += Notification.Like( 307 + data = r.copy( 308 + r.kind, 309 + r.post, 310 + r.authors.sortedByDescending { it.timestamp }, 311 + r.timestamp 312 + ) 313 + ) 314 + } 315 + } 316 + 317 + RepeatableNotification.Repost -> {} 318 + } 233 319 } 320 + 321 + notifs.list = notifs.list.sortedByDescending { it.createdAt() } 234 322 235 323 uiState = uiState.copy( 236 - notifications = uiState.notifications + notifs, 324 + notifications = Notifications(list = uiState.notifications.list + notifs.list), 237 325 notificationsCursor = rawNotifs.cursor, 238 326 isFetchingMoreNotifications = false, 239 327 ) 328 + 240 329 then() 241 330 } 242 331 }