A cheap attempt at a native Bluesky client for Android

Notifications: Show user card for new followers, render quotes

Need to fix quotes with media

+159 -34
+64 -9
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 + import androidx.compose.foundation.Image 3 4 import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Column 4 6 import androidx.compose.foundation.layout.PaddingValues 7 + import androidx.compose.foundation.layout.Row 8 + import androidx.compose.foundation.layout.fillMaxWidth 5 9 import androidx.compose.foundation.layout.padding 10 + import androidx.compose.foundation.layout.size 11 + import androidx.compose.foundation.layout.wrapContentHeight 6 12 import androidx.compose.foundation.lazy.LazyColumn 7 13 import androidx.compose.foundation.lazy.LazyListState 8 14 import androidx.compose.foundation.lazy.items 15 + import androidx.compose.material.icons.Icons 16 + import androidx.compose.material.icons.filled.Add 9 17 import androidx.compose.material3.Card 10 18 import androidx.compose.material3.CardDefaults 19 + import androidx.compose.material3.MaterialTheme 20 + import androidx.compose.material3.OutlinedCard 21 + import androidx.compose.material3.Text 11 22 import androidx.compose.runtime.Composable 12 23 import androidx.compose.runtime.LaunchedEffect 13 24 import androidx.compose.runtime.derivedStateOf 14 25 import androidx.compose.runtime.getValue 15 26 import androidx.compose.runtime.remember 27 + import androidx.compose.ui.Alignment 16 28 import androidx.compose.ui.Modifier 29 + import androidx.compose.ui.graphics.ColorFilter 17 30 import androidx.compose.ui.unit.dp 18 31 import industries.geesawra.monarch.datalayer.Notification 19 32 import industries.geesawra.monarch.datalayer.SkeetData ··· 100 113 onShowThread: (SkeetData) -> Unit = {}, 101 114 ) { 102 115 when (notification) { 103 - is Notification.Follow -> SkeetView( 104 - skeet = SkeetData( 105 - authorName = (notification.follow.displayName 106 - ?: notification.follow.handle).toString() + " followed you!", 107 - authorAvatarURL = notification.follow.avatar.toString(), 108 - ), 109 - nested = true 110 - ) 116 + is Notification.Follow -> { 117 + Column { 118 + Row( 119 + verticalAlignment = Alignment.Top, 120 + horizontalArrangement = Arrangement.Start, 121 + modifier = Modifier 122 + .padding( 123 + top = 16.dp, 124 + start = 16.dp, 125 + end = 16.dp, 126 + bottom = 4.dp 127 + ) 128 + .fillMaxWidth() 129 + .wrapContentHeight() 130 + ) { 131 + Image( 132 + imageVector = Icons.Default.Add, 133 + contentDescription = "New follower icon", 134 + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.inverseSurface), 135 + modifier = Modifier.size(24.dp) 136 + ) 137 + Text( 138 + text = (notification.follow.displayName 139 + ?: notification.follow.handle).toString() + " followed you!", 140 + color = MaterialTheme.colorScheme.onSurface, 141 + style = MaterialTheme.typography.bodyLarge, 142 + ) 143 + } 144 + OutlinedCard( 145 + modifier = Modifier.padding( 146 + top = 8.dp, 147 + start = 40.dp, 148 + end = 16.dp, 149 + bottom = 8.dp 150 + ), 151 + ) { 152 + SkeetView( 153 + skeet = SkeetData( 154 + authorName = (notification.follow.displayName 155 + ?: notification.follow.handle).toString(), 156 + authorAvatarURL = notification.follow.avatar?.uri, 157 + authorHandle = notification.follow.handle, 158 + content = notification.follow.description ?: "" 159 + ), 160 + nested = true 161 + ) 162 + } 163 + } 164 + } 111 165 112 166 is Notification.Like -> LikeRepostRowView( 113 167 data = notification.data, ··· 129 183 skeet = SkeetData.fromPost( 130 184 notification.parent, 131 185 notification.quote, 132 - notification.author 186 + notification.author, 187 + notification.quotedPost 133 188 ), 134 189 onReplyTap = onReplyTap, 135 190 )
+1 -1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 90 90 color = Color.Transparent, 91 91 modifier = 92 92 modifier 93 - .padding(top = 8.dp, start = 16.dp, end = 16.dp) 93 + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 94 94 .background(Color.Transparent) 95 95 .clickable { 96 96 Log.d("SkeetView", skeet.content)
+10 -12
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 87 87 val replies: Long? = null, 88 88 val uri: AtUri = AtUri(""), 89 89 val cid: Cid = Cid(""), 90 + val did: Did? = null, 90 91 val didRepost: Boolean = false, 91 92 val didLike: Boolean = false, 92 93 val authorAvatarURL: String? = null, ··· 129 130 createdAt = content.createdAt.toStdlibInstant(), 130 131 following = post.post.author.viewer?.following != null, 131 132 follower = post.post.author.viewer?.followedBy != null, 133 + did = post.post.author.did, 132 134 ) 133 135 134 136 sd.replyToNotFollowing = run { ··· 191 193 createdAt = content.createdAt.toStdlibInstant(), 192 194 following = author.viewer?.following != null, 193 195 follower = author.viewer?.followedBy != null, 196 + did = author.did, 194 197 ) 195 198 } 196 199 ··· 202 205 authorName = author.displayName, 203 206 authorHandle = author.handle, 204 207 authorLabels = author.labels, 208 + did = author.did, 205 209 content = post.text, 206 210 embed = when (post.embed) { 207 211 is PostEmbedUnion.External -> { ··· 371 375 // TODO: fix embeds 372 376 createdAt = post.createdAt.toStdlibInstant(), 373 377 facets = post.facets, 378 + did = author.did, 374 379 ) 375 380 } 376 381 ··· 380 385 author: ProfileView, 381 386 embed: PostViewEmbedUnion? 382 387 ): SkeetData { 383 - return SkeetData( 384 - cid = parent.first, 385 - uri = parent.second, 386 - authorAvatarURL = author.avatar?.uri, 387 - authorName = author.displayName, 388 - authorHandle = author.handle, 389 - authorLabels = author.labels, 390 - content = post.text, 391 - embed = embed, 392 - createdAt = post.createdAt.toStdlibInstant(), 393 - facets = post.facets, 394 - ) 388 + val sd = fromPost(parent, post, author) 389 + sd.embed = embed 390 + return sd 395 391 } 396 392 397 393 ··· 433 429 reply = null, 434 430 createdAt = content.createdAt.toStdlibInstant(), 435 431 facets = content.facets, 432 + did = post.author.did, 436 433 ) 437 434 } 438 435 } ··· 667 664 data class Quote( 668 665 val parent: Pair<Cid, AtUri>, 669 666 val quote: Post, 667 + val quotedPost: PostViewEmbedUnion, 670 668 val author: ProfileView, 671 669 val createdAt: Instant, 672 670 val new: Boolean
+84 -12
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 10 10 import androidx.lifecycle.ViewModel 11 11 import androidx.lifecycle.viewModelScope 12 12 import app.bsky.actor.ProfileView 13 + import app.bsky.actor.ProfileViewBasic 13 14 import app.bsky.actor.ProfileViewDetailed 15 + import app.bsky.embed.RecordView 16 + import app.bsky.embed.RecordViewRecord 17 + import app.bsky.embed.RecordViewRecordUnion 14 18 import app.bsky.feed.GeneratorView 15 19 import app.bsky.feed.GetPostThreadResponseThreadUnion 16 20 import app.bsky.feed.Like 17 21 import app.bsky.feed.Post 22 + import app.bsky.feed.PostEmbedUnion 18 23 import app.bsky.feed.PostReplyRef 24 + import app.bsky.feed.PostViewEmbedUnion 19 25 import app.bsky.feed.Repost 20 26 import app.bsky.feed.ThreadViewPostReplieUnion 21 27 import app.bsky.graph.Follow ··· 30 36 import kotlinx.coroutines.joinAll 31 37 import kotlinx.coroutines.launch 32 38 import kotlinx.datetime.toStdlibInstant 39 + import kotlinx.serialization.json.Json 33 40 import sh.christian.ozone.api.AtUri 34 41 import sh.christian.ozone.api.Cid 35 42 import sh.christian.ozone.api.Did 36 43 import sh.christian.ozone.api.RKey 37 44 import sh.christian.ozone.api.model.JsonContent 45 + import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 38 46 import kotlin.coroutines.cancellation.CancellationException 39 47 import kotlin.time.ExperimentalTime 40 48 import kotlin.time.Instant ··· 245 253 l.subject.uri 246 254 } 247 255 256 + ListNotificationsReason.Quote -> { 257 + val l: Post = it.record.decodeAs() 258 + val e = l.embed 259 + when (e) { 260 + is PostEmbedUnion.Record -> { 261 + e.value.record.uri 262 + } 263 + 264 + is PostEmbedUnion.RecordWithMedia -> { 265 + e.value.record.record.uri 266 + } 267 + 268 + else -> null 269 + } 270 + } 271 + 248 272 else -> null 249 273 } 250 274 } 251 275 252 - val posts = postsToFetch.chunked(25).fold(mapOf<AtUri, SkeetData>()) { acc, chunk -> 253 - acc + bskyConn.getPosts(chunk).getOrThrow() 254 - .associate { 255 - it.uri to SkeetData.fromPost( 256 - (it.cid to it.uri), 257 - it.record.decodeAs<Post>(), 258 - it.author 259 - ) 260 - } 261 - } 276 + val posts = 277 + postsToFetch.chunked(25).fold(mapOf<AtUri, Pair<SkeetData, Post>>()) { acc, chunk -> 278 + acc + bskyConn.getPosts(chunk).getOrThrow() 279 + .associate { 280 + val record = it.record.decodeAs<Post>() 281 + it.uri to (SkeetData.fromPost( 282 + (it.cid to it.uri), 283 + record, 284 + it.author 285 + ) to record) 286 + } 287 + } 262 288 263 289 // we could bulk request posts here and avoid much of the network IO 264 290 var notifs = rawNotifs.notifications.mapNotNull { ··· 274 300 275 301 repeatable += Notification.RawLike( 276 302 l.subject, 277 - lp, 303 + lp.first, 278 304 it.author, 279 305 l.createdAt.toStdlibInstant(), 280 306 !it.isRead ··· 296 322 297 323 ListNotificationsReason.Quote -> { 298 324 val p: Post = it.record.decodeAs() 325 + val quotedUrl = when (p.embed) { 326 + is PostEmbedUnion.Record -> { 327 + (p.embed as PostEmbedUnion.Record).value.record.uri 328 + } 329 + 330 + is PostEmbedUnion.RecordWithMedia -> { 331 + (p.embed as PostEmbedUnion.RecordWithMedia).value.record.record.uri 332 + } 333 + 334 + else -> null 335 + 336 + } 337 + 338 + if (quotedUrl == null) { 339 + throw Exception("quote notification without a record or record media!") 340 + } 341 + val lp = posts[quotedUrl]!! 342 + val skeetData = lp.first 343 + val post = lp.second 299 344 Notification.Quote( 300 345 Pair(it.cid, it.uri), 301 346 p, 347 + PostViewEmbedUnion.RecordView( 348 + value = RecordView( 349 + record = RecordViewRecordUnion.ViewRecord( 350 + value = RecordViewRecord( 351 + uri = skeetData.uri, 352 + cid = skeetData.cid, 353 + author = ProfileViewBasic( 354 + did = skeetData.did!!, 355 + handle = skeetData.authorHandle!!, 356 + displayName = skeetData.authorName, 357 + avatar = skeetData.authorAvatarURL?.let { uri -> 358 + sh.christian.ozone.api.Uri( 359 + uri 360 + ) 361 + }, 362 + associated = it.author.associated, 363 + viewer = it.author.viewer, 364 + labels = it.author.labels, 365 + createdAt = it.author.createdAt, 366 + verification = it.author.verification, 367 + ), 368 + value = Json.encodeAsJsonContent(post), 369 + indexedAt = post.createdAt 370 + ) 371 + ) 372 + ) 373 + ), // TODO: handle recordwithmedia 302 374 it.author, 303 375 p.createdAt.toStdlibInstant(), 304 376 !it.isRead ··· 321 393 val rpp = posts[p.subject.uri]!! 322 394 repeatable += Notification.RawRepost( 323 395 p.subject, 324 - rpp, 396 + rpp.first, 325 397 it.author, 326 398 p.createdAt.toStdlibInstant(), 327 399 !it.isRead