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 package industries.geesawra.monarch 2 3 import androidx.compose.foundation.layout.Arrangement 4 import androidx.compose.foundation.layout.PaddingValues 5 import androidx.compose.foundation.layout.padding 6 import androidx.compose.foundation.lazy.LazyColumn 7 import androidx.compose.foundation.lazy.LazyListState 8 import androidx.compose.foundation.lazy.items 9 import androidx.compose.material3.Card 10 import androidx.compose.material3.CardDefaults 11 import androidx.compose.runtime.Composable 12 import androidx.compose.runtime.LaunchedEffect 13 import androidx.compose.runtime.derivedStateOf 14 import androidx.compose.runtime.getValue 15 import androidx.compose.runtime.remember 16 import androidx.compose.ui.Modifier 17 import androidx.compose.ui.unit.dp 18 import industries.geesawra.monarch.datalayer.Notification 19 import industries.geesawra.monarch.datalayer.SkeetData ··· 100 onShowThread: (SkeetData) -> Unit = {}, 101 ) { 102 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 - ) 111 112 is Notification.Like -> LikeRepostRowView( 113 data = notification.data, ··· 129 skeet = SkeetData.fromPost( 130 notification.parent, 131 notification.quote, 132 - notification.author 133 ), 134 onReplyTap = onReplyTap, 135 )
··· 1 package industries.geesawra.monarch 2 3 + import androidx.compose.foundation.Image 4 import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Column 6 import androidx.compose.foundation.layout.PaddingValues 7 + import androidx.compose.foundation.layout.Row 8 + import androidx.compose.foundation.layout.fillMaxWidth 9 import androidx.compose.foundation.layout.padding 10 + import androidx.compose.foundation.layout.size 11 + import androidx.compose.foundation.layout.wrapContentHeight 12 import androidx.compose.foundation.lazy.LazyColumn 13 import androidx.compose.foundation.lazy.LazyListState 14 import androidx.compose.foundation.lazy.items 15 + import androidx.compose.material.icons.Icons 16 + import androidx.compose.material.icons.filled.Add 17 import androidx.compose.material3.Card 18 import androidx.compose.material3.CardDefaults 19 + import androidx.compose.material3.MaterialTheme 20 + import androidx.compose.material3.OutlinedCard 21 + import androidx.compose.material3.Text 22 import androidx.compose.runtime.Composable 23 import androidx.compose.runtime.LaunchedEffect 24 import androidx.compose.runtime.derivedStateOf 25 import androidx.compose.runtime.getValue 26 import androidx.compose.runtime.remember 27 + import androidx.compose.ui.Alignment 28 import androidx.compose.ui.Modifier 29 + import androidx.compose.ui.graphics.ColorFilter 30 import androidx.compose.ui.unit.dp 31 import industries.geesawra.monarch.datalayer.Notification 32 import industries.geesawra.monarch.datalayer.SkeetData ··· 113 onShowThread: (SkeetData) -> Unit = {}, 114 ) { 115 when (notification) { 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 + } 165 166 is Notification.Like -> LikeRepostRowView( 167 data = notification.data, ··· 183 skeet = SkeetData.fromPost( 184 notification.parent, 185 notification.quote, 186 + notification.author, 187 + notification.quotedPost 188 ), 189 onReplyTap = onReplyTap, 190 )
+1 -1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 90 color = Color.Transparent, 91 modifier = 92 modifier 93 - .padding(top = 8.dp, start = 16.dp, end = 16.dp) 94 .background(Color.Transparent) 95 .clickable { 96 Log.d("SkeetView", skeet.content)
··· 90 color = Color.Transparent, 91 modifier = 92 modifier 93 + .padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) 94 .background(Color.Transparent) 95 .clickable { 96 Log.d("SkeetView", skeet.content)
+10 -12
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 87 val replies: Long? = null, 88 val uri: AtUri = AtUri(""), 89 val cid: Cid = Cid(""), 90 val didRepost: Boolean = false, 91 val didLike: Boolean = false, 92 val authorAvatarURL: String? = null, ··· 129 createdAt = content.createdAt.toStdlibInstant(), 130 following = post.post.author.viewer?.following != null, 131 follower = post.post.author.viewer?.followedBy != null, 132 ) 133 134 sd.replyToNotFollowing = run { ··· 191 createdAt = content.createdAt.toStdlibInstant(), 192 following = author.viewer?.following != null, 193 follower = author.viewer?.followedBy != null, 194 ) 195 } 196 ··· 202 authorName = author.displayName, 203 authorHandle = author.handle, 204 authorLabels = author.labels, 205 content = post.text, 206 embed = when (post.embed) { 207 is PostEmbedUnion.External -> { ··· 371 // TODO: fix embeds 372 createdAt = post.createdAt.toStdlibInstant(), 373 facets = post.facets, 374 ) 375 } 376 ··· 380 author: ProfileView, 381 embed: PostViewEmbedUnion? 382 ): 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 - ) 395 } 396 397 ··· 433 reply = null, 434 createdAt = content.createdAt.toStdlibInstant(), 435 facets = content.facets, 436 ) 437 } 438 } ··· 667 data class Quote( 668 val parent: Pair<Cid, AtUri>, 669 val quote: Post, 670 val author: ProfileView, 671 val createdAt: Instant, 672 val new: Boolean
··· 87 val replies: Long? = null, 88 val uri: AtUri = AtUri(""), 89 val cid: Cid = Cid(""), 90 + val did: Did? = null, 91 val didRepost: Boolean = false, 92 val didLike: Boolean = false, 93 val authorAvatarURL: String? = null, ··· 130 createdAt = content.createdAt.toStdlibInstant(), 131 following = post.post.author.viewer?.following != null, 132 follower = post.post.author.viewer?.followedBy != null, 133 + did = post.post.author.did, 134 ) 135 136 sd.replyToNotFollowing = run { ··· 193 createdAt = content.createdAt.toStdlibInstant(), 194 following = author.viewer?.following != null, 195 follower = author.viewer?.followedBy != null, 196 + did = author.did, 197 ) 198 } 199 ··· 205 authorName = author.displayName, 206 authorHandle = author.handle, 207 authorLabels = author.labels, 208 + did = author.did, 209 content = post.text, 210 embed = when (post.embed) { 211 is PostEmbedUnion.External -> { ··· 375 // TODO: fix embeds 376 createdAt = post.createdAt.toStdlibInstant(), 377 facets = post.facets, 378 + did = author.did, 379 ) 380 } 381 ··· 385 author: ProfileView, 386 embed: PostViewEmbedUnion? 387 ): SkeetData { 388 + val sd = fromPost(parent, post, author) 389 + sd.embed = embed 390 + return sd 391 } 392 393 ··· 429 reply = null, 430 createdAt = content.createdAt.toStdlibInstant(), 431 facets = content.facets, 432 + did = post.author.did, 433 ) 434 } 435 } ··· 664 data class Quote( 665 val parent: Pair<Cid, AtUri>, 666 val quote: Post, 667 + val quotedPost: PostViewEmbedUnion, 668 val author: ProfileView, 669 val createdAt: Instant, 670 val new: Boolean
+84 -12
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 10 import androidx.lifecycle.ViewModel 11 import androidx.lifecycle.viewModelScope 12 import app.bsky.actor.ProfileView 13 import app.bsky.actor.ProfileViewDetailed 14 import app.bsky.feed.GeneratorView 15 import app.bsky.feed.GetPostThreadResponseThreadUnion 16 import app.bsky.feed.Like 17 import app.bsky.feed.Post 18 import app.bsky.feed.PostReplyRef 19 import app.bsky.feed.Repost 20 import app.bsky.feed.ThreadViewPostReplieUnion 21 import app.bsky.graph.Follow ··· 30 import kotlinx.coroutines.joinAll 31 import kotlinx.coroutines.launch 32 import kotlinx.datetime.toStdlibInstant 33 import sh.christian.ozone.api.AtUri 34 import sh.christian.ozone.api.Cid 35 import sh.christian.ozone.api.Did 36 import sh.christian.ozone.api.RKey 37 import sh.christian.ozone.api.model.JsonContent 38 import kotlin.coroutines.cancellation.CancellationException 39 import kotlin.time.ExperimentalTime 40 import kotlin.time.Instant ··· 245 l.subject.uri 246 } 247 248 else -> null 249 } 250 } 251 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 - } 262 263 // we could bulk request posts here and avoid much of the network IO 264 var notifs = rawNotifs.notifications.mapNotNull { ··· 274 275 repeatable += Notification.RawLike( 276 l.subject, 277 - lp, 278 it.author, 279 l.createdAt.toStdlibInstant(), 280 !it.isRead ··· 296 297 ListNotificationsReason.Quote -> { 298 val p: Post = it.record.decodeAs() 299 Notification.Quote( 300 Pair(it.cid, it.uri), 301 p, 302 it.author, 303 p.createdAt.toStdlibInstant(), 304 !it.isRead ··· 321 val rpp = posts[p.subject.uri]!! 322 repeatable += Notification.RawRepost( 323 p.subject, 324 - rpp, 325 it.author, 326 p.createdAt.toStdlibInstant(), 327 !it.isRead
··· 10 import androidx.lifecycle.ViewModel 11 import androidx.lifecycle.viewModelScope 12 import app.bsky.actor.ProfileView 13 + import app.bsky.actor.ProfileViewBasic 14 import app.bsky.actor.ProfileViewDetailed 15 + import app.bsky.embed.RecordView 16 + import app.bsky.embed.RecordViewRecord 17 + import app.bsky.embed.RecordViewRecordUnion 18 import app.bsky.feed.GeneratorView 19 import app.bsky.feed.GetPostThreadResponseThreadUnion 20 import app.bsky.feed.Like 21 import app.bsky.feed.Post 22 + import app.bsky.feed.PostEmbedUnion 23 import app.bsky.feed.PostReplyRef 24 + import app.bsky.feed.PostViewEmbedUnion 25 import app.bsky.feed.Repost 26 import app.bsky.feed.ThreadViewPostReplieUnion 27 import app.bsky.graph.Follow ··· 36 import kotlinx.coroutines.joinAll 37 import kotlinx.coroutines.launch 38 import kotlinx.datetime.toStdlibInstant 39 + import kotlinx.serialization.json.Json 40 import sh.christian.ozone.api.AtUri 41 import sh.christian.ozone.api.Cid 42 import sh.christian.ozone.api.Did 43 import sh.christian.ozone.api.RKey 44 import sh.christian.ozone.api.model.JsonContent 45 + import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 46 import kotlin.coroutines.cancellation.CancellationException 47 import kotlin.time.ExperimentalTime 48 import kotlin.time.Instant ··· 253 l.subject.uri 254 } 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 + 272 else -> null 273 } 274 } 275 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 + } 288 289 // we could bulk request posts here and avoid much of the network IO 290 var notifs = rawNotifs.notifications.mapNotNull { ··· 300 301 repeatable += Notification.RawLike( 302 l.subject, 303 + lp.first, 304 it.author, 305 l.createdAt.toStdlibInstant(), 306 !it.isRead ··· 322 323 ListNotificationsReason.Quote -> { 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 344 Notification.Quote( 345 Pair(it.cid, it.uri), 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 374 it.author, 375 p.createdAt.toStdlibInstant(), 376 !it.isRead ··· 393 val rpp = posts[p.subject.uri]!! 394 repeatable += Notification.RawRepost( 395 p.subject, 396 + rpp.first, 397 it.author, 398 p.createdAt.toStdlibInstant(), 399 !it.isRead