A cheap attempt at a native Bluesky client for Android

feat: Render notifications in NotificationsView

This commit introduces the rendering of notifications within the Notifications tab. Instead of showing raw data, `NotificationsView` now uses `SkeetView` to display different notification types like follows, likes, reposts, replies, and quotes.

Specific changes include:
* A sealed `Notification` class was created to model different notification types.
* The `TimelineViewModel` now processes raw notification data from the API into this new `Notification` model. This involves fetching related records (like the post that was liked) to provide context.
* Added `fetchRecord` and `fetchActor` methods to the Bluesky data layer to retrieve individual posts and user profiles.
* `SkeetView` can now be tapped to handle replies from within the notifications list.

+292 -25
+5 -1
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 1 + @file:OptIn(ExperimentalTime::class) 2 + 1 3 package industries.geesawra.monarch 2 4 3 5 import android.widget.Toast ··· 68 70 import industries.geesawra.monarch.datalayer.TimelineViewModel 69 71 import kotlinx.coroutines.CoroutineScope 70 72 import kotlinx.coroutines.launch 73 + import kotlin.time.ExperimentalTime 71 74 72 75 enum class TabBarDestinations( 73 76 @param:StringRes val label: Int, ··· 361 364 TabBarDestinations.NOTIFICATIONS -> NotificationsView( 362 365 viewModel = timelineViewModel, 363 366 state = notificationsState, 364 - modifier = Modifier.padding(values) 367 + modifier = Modifier.padding(values), 368 + onReplyTap = onReplyTap 365 369 ) { 366 370 isRefreshing.value = false 367 371 }
+72 -4
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 9 9 import androidx.compose.foundation.lazy.LazyColumn 10 10 import androidx.compose.foundation.lazy.LazyListState 11 11 import androidx.compose.material3.CircularProgressIndicator 12 - import androidx.compose.material3.Text 13 12 import androidx.compose.runtime.Composable 14 13 import androidx.compose.runtime.LaunchedEffect 15 14 import androidx.compose.runtime.derivedStateOf ··· 18 17 import androidx.compose.ui.Alignment 19 18 import androidx.compose.ui.Modifier 20 19 import androidx.compose.ui.unit.dp 20 + import industries.geesawra.monarch.datalayer.Notification 21 + import industries.geesawra.monarch.datalayer.SkeetData 21 22 import industries.geesawra.monarch.datalayer.TimelineViewModel 23 + import kotlin.time.ExperimentalTime 22 24 25 + @ExperimentalTime 23 26 @Composable 24 27 fun NotificationsView( 25 28 viewModel: TimelineViewModel, 26 29 state: LazyListState, 27 30 modifier: Modifier = Modifier, 31 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 28 32 doneRefresh: () -> Unit = {}, 29 33 ) { 30 34 LaunchedEffect(key1 = viewModel.uiState.notifications.isEmpty()) { ··· 41 45 verticalArrangement = Arrangement.spacedBy(8.dp), 42 46 ) { 43 47 viewModel.uiState.notifications.forEach { notif -> 44 - item() {// TODO: group notification by (cid, uri) and kind 45 - Text( 46 - notif.record.toString() 48 + item() { 49 + RenderNotification( 50 + viewModel = viewModel, 51 + notification = notif, 52 + onReplyTap = onReplyTap 47 53 ) 48 54 } 49 55 } ··· 87 93 if (endOfListReached && viewModel.uiState.notifications.isNotEmpty()) { 88 94 viewModel.fetchNotifications() 89 95 } 96 + } 97 + } 98 + 99 + @ExperimentalTime 100 + @Composable 101 + private fun RenderNotification( 102 + viewModel: TimelineViewModel, 103 + notification: Notification, 104 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 105 + ) { 106 + when (notification) { 107 + is Notification.Follow -> SkeetView( 108 + skeet = SkeetData( 109 + authorName = (notification.follow.displayName 110 + ?: notification.follow.handle).toString() + " followed you!", 111 + authorAvatarURL = notification.follow.avatar.toString(), 112 + ), 113 + nested = true 114 + ) 115 + 116 + is Notification.Like -> SkeetView( 117 + skeet = SkeetData( 118 + authorName = (notification.author.displayName 119 + ?: notification.author.handle).toString() + " liked your post", 120 + authorAvatarURL = notification.author.avatar.toString() 121 + ), 122 + nested = true 123 + ) 124 + 125 + 126 + // is Notification.Mention -> TODO() 127 + is Notification.Quote -> SkeetView( 128 + viewModel = viewModel, 129 + skeet = SkeetData.fromPost( 130 + notification.parent, 131 + notification.quote, 132 + notification.author 133 + ), 134 + onReplyTap = onReplyTap, 135 + ) 136 + 137 + is Notification.Reply -> SkeetView( 138 + viewModel = viewModel, 139 + skeet = SkeetData.fromPost( 140 + notification.parent, 141 + notification.reply, 142 + notification.author 143 + ), 144 + onReplyTap = onReplyTap, 145 + ) 146 + 147 + is Notification.Repost -> SkeetView( 148 + skeet = SkeetData( 149 + authorName = (notification.author.displayName 150 + ?: notification.author.handle).toString() + " reposted your post", 151 + authorAvatarURL = notification.author.avatar.toString() 152 + ), 153 + nested = true 154 + ) 155 + 156 + else -> {} 157 + 90 158 } 91 159 }
+6 -3
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 5 5 import android.content.Context 6 6 import android.net.Uri 7 7 import androidx.browser.customtabs.CustomTabsIntent 8 + import androidx.compose.foundation.background 8 9 import androidx.compose.foundation.clickable 9 10 import androidx.compose.foundation.layout.Arrangement 10 11 import androidx.compose.foundation.layout.Column ··· 28 29 import androidx.compose.ui.Alignment 29 30 import androidx.compose.ui.Modifier 30 31 import androidx.compose.ui.draw.clip 32 + import androidx.compose.ui.graphics.Color 31 33 import androidx.compose.ui.layout.ContentScale 32 34 import androidx.compose.ui.platform.LocalContext 33 35 import androidx.compose.ui.text.font.FontWeight ··· 59 61 @Composable 60 62 fun SkeetView( 61 63 modifier: Modifier = Modifier, 64 + color: Color = MaterialTheme.colorScheme.surface, 62 65 viewModel: TimelineViewModel? = null, 63 66 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 64 67 skeet: SkeetData, ··· 82 85 val hasParent = parent != null 83 86 84 87 Surface( 85 - color = MaterialTheme.colorScheme.surface, 88 + color = color, 86 89 modifier = if (!inThread && !hasParent) { 87 90 modifier.padding(start = 16.dp, end = 16.dp) 88 91 } else { 89 92 modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp) 90 - } 93 + }.background(color) 91 94 ) { 92 95 Row( 93 96 verticalAlignment = Alignment.Top, ··· 315 318 HorizontalDivider( 316 319 color = MaterialTheme.colorScheme.outlineVariant, 317 320 ) 318 - 321 + 319 322 Text( 320 323 text = ev.title, 321 324 style = MaterialTheme.typography.titleSmall,
+10
app/src/main/java/industries/geesawra/monarch/TimelinePostActionsView.kt
··· 45 45 import industries.geesawra.monarch.datalayer.TimelineViewModel 46 46 import kotlinx.coroutines.delay 47 47 import sh.christian.ozone.api.AtUri 48 + import sh.christian.ozone.api.Did 49 + import sh.christian.ozone.api.Nsid 48 50 import sh.christian.ozone.api.RKey 49 51 50 52 ··· 84 86 85 87 fun AtUri.rkey(): RKey { 86 88 return RKey(this.atUri.toUri().lastPathSegment!!) 89 + } 90 + 91 + fun AtUri.did(): Did { 92 + return Did(this.atUri.toUri().host.toString()) 93 + } 94 + 95 + fun AtUri.collection(): Nsid { 96 + return Nsid(this.atUri.toUri().pathSegments[0]) 87 97 } 88 98 89 99 @Composable
+48
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 11 11 import androidx.datastore.preferences.core.edit 12 12 import androidx.datastore.preferences.core.stringPreferencesKey 13 13 import androidx.datastore.preferences.preferencesDataStore 14 + import app.bsky.actor.GetProfileQueryParams 15 + import app.bsky.actor.GetProfileResponse 14 16 import app.bsky.actor.PreferencesUnion 17 + import app.bsky.actor.ProfileViewDetailed 15 18 import app.bsky.embed.Images 16 19 import app.bsky.embed.ImagesImage 17 20 import app.bsky.embed.Record ··· 37 40 import com.atproto.repo.CreateRecordRequest 38 41 import com.atproto.repo.CreateRecordResponse 39 42 import com.atproto.repo.DeleteRecordRequest 43 + import com.atproto.repo.GetRecordQueryParams 44 + import com.atproto.repo.GetRecordResponse 40 45 import com.atproto.repo.StrongRef 41 46 import com.atproto.repo.UploadBlobResponse 42 47 import com.atproto.server.CreateSessionRequest ··· 44 49 import com.atproto.server.GetServiceAuthQueryParams 45 50 import com.atproto.server.GetServiceAuthResponse 46 51 import com.atproto.server.RefreshSessionResponse 52 + import industries.geesawra.monarch.collection 53 + import industries.geesawra.monarch.did 47 54 import industries.geesawra.monarch.rkey 48 55 import io.ktor.client.HttpClient 49 56 import io.ktor.client.call.body ··· 78 85 import sh.christian.ozone.api.Nsid 79 86 import sh.christian.ozone.api.RKey 80 87 import sh.christian.ozone.api.model.Blob 88 + import sh.christian.ozone.api.model.JsonContent 81 89 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 82 90 import sh.christian.ozone.api.response.AtpResponse 83 91 import kotlin.time.Clock ··· 500 508 return when (postRes) { 501 509 is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 502 510 is AtpResponse.Success<*> -> Result.success(Unit) 511 + } 512 + } 513 + } 514 + 515 + suspend fun fetchRecord(uri: AtUri): Result<JsonContent> { 516 + return runCatching { 517 + create().onFailure { 518 + return Result.failure(LoginException(it.message)) 519 + } 520 + 521 + val ret = client!!.getRecord( 522 + GetRecordQueryParams( 523 + repo = uri.did(), 524 + collection = uri.collection(), 525 + rkey = uri.rkey() 526 + ) 527 + ) 528 + 529 + return when (ret) { 530 + is AtpResponse.Failure<*> -> Result.failure(Exception("Failed fetching record: ${ret.error}")) 531 + is AtpResponse.Success<GetRecordResponse> -> Result.success(ret.response.value) 532 + } 533 + } 534 + } 535 + 536 + suspend fun fetchActor(did: Did): Result<ProfileViewDetailed> { 537 + return runCatching { 538 + create().onFailure { 539 + return Result.failure(LoginException(it.message)) 540 + } 541 + 542 + val ret = client!!.getProfile( 543 + GetProfileQueryParams( 544 + actor = did 545 + ) 546 + ) 547 + 548 + return when (ret) { 549 + is AtpResponse.Failure<*> -> Result.failure(Exception("Failed fetching record: ${ret.error}")) 550 + is AtpResponse.Success<GetProfileResponse> -> Result.success(ret.response) 503 551 } 504 552 } 505 553 }
+71 -4
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 2 2 3 3 package industries.geesawra.monarch.datalayer 4 4 5 + import app.bsky.actor.ProfileView 5 6 import app.bsky.embed.RecordViewRecord 6 7 import app.bsky.embed.RecordViewRecordEmbedUnion 7 8 import app.bsky.feed.FeedViewPost ··· 88 89 ) 89 90 } 90 91 92 + fun fromPost(parent: Pair<Cid, AtUri>, post: Post, author: ProfileView): SkeetData { 93 + return SkeetData( 94 + cid = parent.first, 95 + uri = parent.second, 96 + authorAvatarURL = author.avatar?.uri, 97 + authorName = author.displayName, 98 + authorHandle = author.handle, 99 + authorLabels = author.labels, 100 + content = post.text, 101 + // embed = when (post.embed) { 102 + // is PostEmbedUnion.External -> PostViewEmbedUnion.ExternalView( 103 + // ExternalView( 104 + // ExternalViewExternal( 105 + // uri = (post.embed as PostEmbedUnion.External).value.external.uri, 106 + // title = (post.embed as PostEmbedUnion.External).value.external.title, 107 + // description = (post.embed as PostEmbedUnion.External).value.external.description, 108 + //// thumb = (post.embed as PostEmbedUnion.External).value.external.thumb.toUri(), // TODO fix this 109 + // ) 110 + // ) 111 + // ) 112 + // 113 + // is PostEmbedUnion.Images -> PostViewEmbedUnion.ImagesView( 114 + // ImagesView((post.embed as PostEmbedUnion.Images).value.images.map { 115 + // ImagesViewImage( 116 + // fullsize = it.image, 117 + // alt = TODO(), 118 + // aspectRatio = TODO(), 119 + // ) 120 + // }) 121 + // ) 122 + // 123 + // is PostEmbedUnion.Record -> PostViewEmbedUnion.RecordView( 124 + // RecordView(post.embed.value.record) 125 + // ) 126 + // 127 + // is PostEmbedUnion.RecordWithMedia -> PostViewEmbedUnion.RecordWithMediaView( 128 + // RecordWithMediaView( 129 + // post.embed.value.record, 130 + // post.embed.value.media 131 + // ) 132 + // ) 133 + // 134 + // is PostEmbedUnion.Unknown -> PostViewEmbedUnion.Unknown(post.embed.value) 135 + // is PostEmbedUnion.Video -> PostViewEmbedUnion.VideoView( 136 + // VideoView( 137 + // cid = post.embed.value.cid, thumb = post.embed.value.thumb 138 + // ) 139 + // ) 140 + // 141 + // null -> null 142 + // }, 143 + // TODO: fix embeds 144 + createdAt = post.createdAt.toStdlibInstant() 145 + ) 146 + } 147 + 148 + 91 149 fun fromRecordView(post: RecordViewRecord): SkeetData { 92 150 val content: Post = (post.value.decodeAs()) 93 151 ··· 199 257 return r 200 258 } 201 259 202 - // TODO: detect if thread is made of more than the posts we have, 203 - // if so, show a (more) button to load the thread. 204 - 205 260 fun key(): String { 206 261 return this.uri.split("/").last() 207 262 } ··· 215 270 216 271 return u 217 272 } 218 - } 273 + } 274 + 275 + sealed class Notification { 276 + data class Like(val post: Post, val author: ProfileView) : Notification() 277 + data class Repost(val repost: Post, val author: ProfileView) : Notification() 278 + data class Reply(val parent: Pair<Cid, AtUri>, val reply: Post, val author: ProfileView) : 279 + Notification() 280 + 281 + data class Follow(val follow: ProfileView) : Notification() 282 + data class Mention(val mention: Post, val author: ProfileView) : Notification() 283 + data class Quote(val parent: Pair<Cid, AtUri>, val quote: Post, val author: ProfileView) : 284 + Notification() 285 + }
+80 -13
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 8 8 import androidx.compose.runtime.setValue 9 9 import androidx.lifecycle.ViewModel 10 10 import androidx.lifecycle.viewModelScope 11 + import app.bsky.actor.ProfileViewDetailed 11 12 import app.bsky.feed.GeneratorView 13 + import app.bsky.feed.Like 12 14 import app.bsky.feed.PostReplyRef 13 - import app.bsky.notification.ListNotificationsNotification 15 + import app.bsky.notification.ListNotificationsReason 14 16 import com.atproto.repo.StrongRef 15 17 import dagger.assisted.Assisted 16 18 import dagger.assisted.AssistedFactory ··· 20 22 import kotlinx.coroutines.launch 21 23 import sh.christian.ozone.api.AtUri 22 24 import sh.christian.ozone.api.Cid 25 + import sh.christian.ozone.api.Did 23 26 import sh.christian.ozone.api.RKey 27 + import sh.christian.ozone.api.model.JsonContent 24 28 import kotlin.coroutines.cancellation.CancellationException 25 29 import kotlin.time.Clock 26 30 import kotlin.time.ExperimentalTime ··· 33 37 val feedAvatar: String? = null, 34 38 val feeds: List<GeneratorView> = listOf(), 35 39 val skeets: List<SkeetData> = listOf(), 36 - val notifications: List<ListNotificationsNotification> = listOf(), 40 + val notifications: List<Notification> = listOf(), 37 41 val isFetchingMoreTimeline: Boolean = false, 38 42 val isFetchingMoreNotifications: Boolean = false, 39 43 val authenticated: Boolean = false, ··· 76 80 } 77 81 } 78 82 83 + suspend fun fetchRecord(uri: AtUri): Result<JsonContent> { 84 + val ret = bskyConn.fetchRecord(uri).onFailure { 85 + uiState = when (it) { 86 + is LoginException -> uiState.copy(loginError = it.message) 87 + else -> uiState.copy(error = it.message) 88 + } 89 + }.getOrThrow() 90 + 91 + return Result.success(ret) 92 + } 93 + 94 + suspend fun fetchActor(did: Did): Result<ProfileViewDetailed> { 95 + val ret = bskyConn.fetchActor(did).onFailure { 96 + uiState = when (it) { 97 + is LoginException -> uiState.copy(loginError = it.message) 98 + else -> uiState.copy(error = it.message) 99 + } 100 + }.getOrThrow() 101 + 102 + return Result.success(ret) 103 + } 79 104 80 105 fun fetchTimeline(then: () -> Unit = {}) { 81 106 uiState = uiState.copy(isFetchingMoreTimeline = true) ··· 91 116 uiState.selectedFeed 92 117 } 93 118 }(), uiState.timelineCursor).onSuccess { it -> 119 + val newData = 120 + (uiState.skeets + it.feed.map { SkeetData.fromFeedViewPost(it) }).distinctBy { it.cid } 121 + 94 122 uiState = uiState.copy( 95 - skeets = (uiState.skeets + it.feed.map { SkeetData.fromFeedViewPost(it) }).distinctBy { it.cid }, 123 + skeets = newData, 96 124 timelineCursor = it.cursor, 97 125 isFetchingMoreTimeline = false 98 126 ) ··· 119 147 } 120 148 121 149 notificationsFetchJob = viewModelScope.launch { 122 - bskyConn.notifications(uiState.notificationsCursor, Clock.System.now()) 123 - .onSuccess { it -> 124 - uiState = uiState.copy( 125 - notifications = it.notifications, 126 - notificationsCursor = it.cursor, 127 - isFetchingMoreNotifications = false, 128 - lastDownloadedNotifs = Clock.System.now() 129 - ) 130 - then() 131 - }.onFailure { 150 + val rawNotifs = bskyConn.notifications(uiState.notificationsCursor, Clock.System.now()) 151 + .onFailure { 132 152 if (it is CancellationException) { 133 153 return@onFailure 134 154 } ··· 139 159 isFetchingMoreNotifications = false, 140 160 error = "Failed to fetch notifications: ${it.message}" 141 161 ) 162 + }.getOrThrow() 163 + 164 + val notifs: List<Notification> = rawNotifs.notifications.mapNotNull { 165 + when (it.reason) { 166 + ListNotificationsReason.Follow -> { 167 + Notification.Follow(it.author) 168 + } 169 + 170 + ListNotificationsReason.Like -> { 171 + val l: Like = it.record.decodeAs() 172 + val lp = fetchRecord(l.subject.uri).getOrThrow() 173 + Notification.Like(lp.decodeAs(), it.author) 174 + } 175 + 176 + ListNotificationsReason.Mention -> { 177 + val p: app.bsky.feed.Post = it.record.decodeAs() 178 + Notification.Mention(p, it.author) 179 + } 180 + 181 + ListNotificationsReason.Quote -> { 182 + val p: app.bsky.feed.Post = it.record.decodeAs() 183 + Notification.Quote(Pair(it.cid, it.uri), p, it.author) 184 + } 185 + 186 + ListNotificationsReason.Reply -> { 187 + val p: app.bsky.feed.Post = it.record.decodeAs() 188 + Notification.Reply(Pair(it.cid, it.uri), p, it.author) 189 + } 190 + 191 + ListNotificationsReason.Repost -> { 192 + val p: app.bsky.feed.Repost = it.record.decodeAs() 193 + val pp = fetchRecord(p.subject.uri).getOrThrow() 194 + Notification.Repost(pp.decodeAs(), it.author) 195 + } 196 + 197 + else -> { 198 + null 199 + } 142 200 } 201 + } 202 + 203 + uiState = uiState.copy( 204 + notifications = uiState.notifications + notifs, 205 + notificationsCursor = rawNotifs.cursor, 206 + isFetchingMoreNotifications = false, 207 + lastDownloadedNotifs = Clock.System.now() 208 + ) 209 + then() 143 210 } 144 211 } 145 212