A cheap attempt at a native Bluesky client for Android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: support external embeds and quote reposts

external embeds are tappable, will open a custom tab

also tweak padding for image and videos

geesawra 78c2f4e1 1b87ccc9

+255 -51
+1
app/build.gradle.kts
··· 73 73 implementation("androidx.paging:paging-compose:3.3.0-alpha05") 74 74 implementation("me.saket.telephoto:zoomable:0.17.0") 75 75 implementation("me.saket.telephoto:zoomable-image-coil3:0.17.0") 76 + implementation("androidx.browser:browser:1.9.0") 76 77 implementation(libs.androidx.compose.animation.core.lint) 77 78 implementation(libs.androidx.material3) 78 79 ksp("com.google.dagger:hilt-compiler:2.57.2")
+3 -3
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 40 40 modifier = modifier.fillMaxSize(), 41 41 verticalArrangement = Arrangement.spacedBy(8.dp), 42 42 ) { 43 - viewModel.uiState.skeets.distinctBy { it.post.cid }.forEach { skeet -> 44 - item(key = skeet.post.cid.cid) { 45 - SkeetRowView(viewModel, skeet) 43 + viewModel.uiState.skeets.forEach { skeet -> 44 + item(key = skeet.cid.cid) { 45 + SkeetRowView(viewModel = viewModel, skeet = skeet) 46 46 } 47 47 } 48 48
+144 -44
app/src/main/java/industries/geesawra/jerryno/SkeetRowView.kt
··· 1 1 package industries.geesawra.jerryno 2 2 3 + import androidx.browser.customtabs.CustomTabsIntent 4 + import androidx.compose.foundation.clickable 3 5 import androidx.compose.foundation.layout.Arrangement 4 6 import androidx.compose.foundation.layout.Column 5 7 import androidx.compose.foundation.layout.Row 6 8 import androidx.compose.foundation.layout.fillMaxSize 7 9 import androidx.compose.foundation.layout.fillMaxWidth 10 + import androidx.compose.foundation.layout.height 8 11 import androidx.compose.foundation.layout.heightIn 9 12 import androidx.compose.foundation.layout.padding 10 13 import androidx.compose.foundation.layout.size ··· 14 17 import androidx.compose.material3.FilterChip 15 18 import androidx.compose.material3.HorizontalDivider 16 19 import androidx.compose.material3.MaterialTheme 20 + import androidx.compose.material3.OutlinedCard 17 21 import androidx.compose.material3.Surface 18 22 import androidx.compose.material3.Text 19 23 import androidx.compose.runtime.Composable 20 24 import androidx.compose.ui.Alignment 21 25 import androidx.compose.ui.Modifier 22 26 import androidx.compose.ui.draw.clip 27 + import androidx.compose.ui.layout.ContentScale 23 28 import androidx.compose.ui.platform.LocalContext 24 29 import androidx.compose.ui.text.font.FontWeight 25 30 import androidx.compose.ui.unit.dp 31 + import androidx.core.net.toUri 26 32 import androidx.media3.common.MimeTypes 27 - import app.bsky.feed.FeedViewPost 33 + import app.bsky.embed.RecordView 34 + import app.bsky.embed.RecordViewRecordUnion 28 35 import app.bsky.feed.FeedViewPostReasonUnion 29 - import app.bsky.feed.Post 30 36 import app.bsky.feed.PostViewEmbedUnion 31 37 import app.bsky.feed.ReplyRefParentUnion 32 38 import coil3.compose.AsyncImage 33 39 import coil3.request.ImageRequest 34 40 import coil3.request.crossfade 41 + import industries.geesawra.jerryno.datalayer.SkeetData 35 42 import industries.geesawra.jerryno.datalayer.TimelineViewModel 36 43 import io.sanghun.compose.video.RepeatMode 37 44 import io.sanghun.compose.video.VideoPlayer 38 45 import io.sanghun.compose.video.controller.VideoPlayerControllerConfig 39 46 import io.sanghun.compose.video.uri.VideoPlayerMediaItem 40 - import kotlinx.serialization.json.decodeFromJsonElement 41 - import sh.christian.ozone.BlueskyJson 42 47 43 48 @Composable 44 - fun SkeetRowView(viewModel: TimelineViewModel, skeet: FeedViewPost) { 45 - val likes = skeet.post.likeCount 46 - val reposts = skeet.post.repostCount 47 - val replies = skeet.post.replyCount 48 - 49 + fun SkeetRowView( 50 + modifier: Modifier = Modifier, 51 + viewModel: TimelineViewModel, 52 + skeet: SkeetData, 53 + nested: Boolean = false 54 + ) { 55 + val likes = skeet.likes 56 + val reposts = skeet.reposts 57 + val replies = skeet.replies 49 58 val minSize = 55.dp 50 59 51 60 Surface( 52 61 color = MaterialTheme.colorScheme.surface, 53 - modifier = Modifier.padding(start = 16.dp, end = 16.dp) 62 + modifier = modifier.padding(start = 16.dp, end = 16.dp) 54 63 ) { 55 64 Row( 56 65 verticalAlignment = Alignment.Top, ··· 71 80 ) { 72 81 AsyncImage( 73 82 model = ImageRequest.Builder(LocalContext.current) 74 - .data(skeet.post.author.avatar?.toString()) 83 + .data(skeet.authorAvatarURL) 75 84 .crossfade(true) 76 85 .build(), 77 86 contentDescription = "Avatar", ··· 83 92 SkeetHeader(modifier = Modifier.padding(start = 16.dp), skeet = skeet) 84 93 } 85 94 86 - SkeetContent(skeet) 95 + SkeetContent(viewModel, skeet, nested) 87 96 88 - TimelinePostActionsView( 89 - modifier = Modifier 90 - .fillMaxWidth(), 91 - timelineViewModel = viewModel, 92 - replies = replies, 93 - likes = likes, 94 - reposts = reposts, 95 - postUrl = "https://bsky.app/profile/${skeet.post.author.handle.handle}/post/${ 96 - skeet.post.uri.split( 97 - "/" 98 - ).last() 99 - }", 100 - uri = skeet.post.uri, 101 - cid = skeet.post.cid, 102 - reposted = skeet.post.viewer?.repost != null, 103 - liked = skeet.post.viewer?.like != null, 104 - ) 97 + if (!nested) { 98 + TimelinePostActionsView( 99 + modifier = Modifier 100 + .fillMaxWidth(), 101 + timelineViewModel = viewModel, 102 + replies = replies, 103 + likes = likes, 104 + reposts = reposts, 105 + postUrl = skeet.shareURL(), 106 + uri = skeet.uri, 107 + cid = skeet.cid, 108 + reposted = skeet.didRepost, 109 + liked = skeet.didLike, 110 + ) 105 111 106 - HorizontalDivider( 107 - color = MaterialTheme.colorScheme.outlineVariant 108 - ) 112 + HorizontalDivider( 113 + color = MaterialTheme.colorScheme.outlineVariant 114 + ) 115 + } 109 116 } 110 117 111 118 } ··· 113 120 } 114 121 115 122 @Composable 116 - private fun SkeetContent(skeet: FeedViewPost) { 117 - val content = BlueskyJson.decodeFromJsonElement<Post>(skeet.post.record.value) 123 + private fun SkeetContent( 124 + timelineViewModel: TimelineViewModel, 125 + skeet: SkeetData, 126 + nested: Boolean = false 127 + ) { 128 + val context = LocalContext.current 118 129 119 130 Text( 120 - text = content.text, 131 + text = skeet.content, 121 132 color = MaterialTheme.colorScheme.onSurface, 122 133 style = MaterialTheme.typography.bodyLarge, 123 134 ) 124 135 125 - val embed = skeet.post.embed 126 - 127 - if (embed == null) { 136 + if (skeet.embed == null) { 128 137 return 129 138 } 130 139 140 + val embed = skeet.embed 131 141 132 142 when (embed) { 133 143 is PostViewEmbedUnion.ImagesView -> { ··· 136 146 Card( 137 147 modifier = Modifier 138 148 .fillMaxWidth() 139 - .padding(8.dp) 149 + .padding(top = 8.dp, bottom = 8.dp), 140 150 ) { 141 151 PostImageGallery( 142 152 modifier = Modifier ··· 154 164 modifier = Modifier 155 165 .heightIn(max = 500.dp) 156 166 .fillMaxWidth() 157 - .padding(8.dp) 167 + .padding(top = 8.dp, bottom = 8.dp), 158 168 ) { 159 169 VideoPlayer( 160 170 mediaItems = listOf( ··· 191 201 } 192 202 } 193 203 204 + is PostViewEmbedUnion.ExternalView -> { 205 + val ev = embed.value.external 206 + 207 + OutlinedCard( 208 + modifier = Modifier 209 + .padding(top = 8.dp) 210 + ) { 211 + Column( 212 + modifier = Modifier 213 + .fillMaxSize() 214 + .clickable { 215 + val customTabsIntent = CustomTabsIntent.Builder() 216 + .setShowTitle(true) 217 + .setUrlBarHidingEnabled(true) 218 + .build() 219 + customTabsIntent.launchUrl(context, ev.uri.uri.toUri()) 220 + } 221 + ) { 222 + ev.thumb?.let { 223 + AsyncImage( 224 + model = ImageRequest.Builder(LocalContext.current) 225 + .data(it.uri) 226 + .crossfade(true) 227 + .build(), 228 + contentScale = ContentScale.Crop, 229 + alignment = Alignment.Center, 230 + contentDescription = "External link thumbnail", 231 + modifier = Modifier 232 + .height(180.dp) 233 + .fillMaxWidth() 234 + ) 235 + 236 + HorizontalDivider( 237 + color = MaterialTheme.colorScheme.outlineVariant, 238 + ) 239 + } 240 + Text( 241 + text = ev.title, 242 + style = MaterialTheme.typography.titleSmall, 243 + fontWeight = FontWeight.Bold, 244 + modifier = Modifier 245 + .fillMaxSize() 246 + .padding(top = 8.dp, bottom = 4.dp, start = 8.dp, end = 8.dp), 247 + maxLines = 3 248 + ) 249 + Text( 250 + text = ev.description, 251 + style = MaterialTheme.typography.bodyMedium, 252 + modifier = Modifier 253 + .fillMaxSize() 254 + .padding(bottom = 8.dp, start = 8.dp, end = 8.dp), 255 + maxLines = 8 256 + ) 257 + } 258 + 259 + } 260 + } 261 + 262 + is PostViewEmbedUnion.RecordView -> run { 263 + if (nested) { 264 + return@run 265 + } 266 + 267 + OutlinedCard( 268 + modifier = Modifier.padding(top = 8.dp) 269 + ) { 270 + RecordView( 271 + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), 272 + timelineViewModel, 273 + embed.value 274 + ) 275 + } 276 + } 277 + 194 278 else -> {} 195 279 } 196 280 197 281 } 198 282 199 283 @Composable 200 - private fun SkeetHeader(skeet: FeedViewPost, modifier: Modifier = Modifier) { 201 - val authorName = skeet.post.author.displayName ?: skeet.post.author.handle.toString() 284 + private fun RecordView( 285 + modifier: Modifier = Modifier, 286 + viewModel: TimelineViewModel, 287 + rv: RecordView 288 + ) { 289 + val rv = rv.record 290 + when (rv) { 291 + is RecordViewRecordUnion.ViewRecord -> { 292 + SkeetRowView(modifier, viewModel, SkeetData.fromRecordView(rv.value), nested = true) 293 + } 294 + 295 + else -> {} 296 + } 297 + } 298 + 299 + @Composable 300 + private fun SkeetHeader(skeet: SkeetData, modifier: Modifier = Modifier) { 301 + val authorName = skeet.authorName ?: skeet.authorHandle.handle 202 302 203 303 Column(modifier = modifier) { 204 304 skeet.reason?.let { ··· 228 328 ) 229 329 230 330 Text( 231 - text = "@" + skeet.post.author.handle.handle, 331 + text = "@" + skeet.authorHandle, 232 332 color = MaterialTheme.colorScheme.onSurfaceVariant, 233 333 style = MaterialTheme.typography.bodySmall, 234 334 modifier = Modifier 235 335 .padding(top = 4.dp), 236 336 ) 237 337 238 - skeet.post.author.labels.forEach { 338 + skeet.authorLabels.forEach { 239 339 it.neg?.let { it -> 240 340 if (!it) { 241 341 return@forEach
+104
app/src/main/java/industries/geesawra/jerryno/datalayer/Models.kt
··· 1 + package industries.geesawra.jerryno.datalayer 2 + 3 + import app.bsky.embed.RecordViewRecord 4 + import app.bsky.embed.RecordViewRecordEmbedUnion 5 + import app.bsky.feed.FeedViewPost 6 + import app.bsky.feed.FeedViewPostReasonUnion 7 + import app.bsky.feed.Post 8 + import app.bsky.feed.PostViewEmbedUnion 9 + import app.bsky.feed.ReplyRef 10 + import com.atproto.label.Label 11 + import sh.christian.ozone.api.AtUri 12 + import sh.christian.ozone.api.Cid 13 + import sh.christian.ozone.api.Handle 14 + 15 + data class SkeetData( 16 + val likes: Long?, 17 + val reposts: Long?, 18 + val replies: Long?, 19 + val uri: AtUri, 20 + val cid: Cid, 21 + val didRepost: Boolean, 22 + val didLike: Boolean, 23 + val authorAvatarURL: String?, 24 + val authorName: String?, 25 + val authorHandle: Handle, 26 + val authorLabels: List<Label>, 27 + val content: String, 28 + val embed: PostViewEmbedUnion?, 29 + val reason: FeedViewPostReasonUnion?, 30 + val reply: ReplyRef?, 31 + ) { 32 + companion object { 33 + fun fromFeedViewPost(post: FeedViewPost): SkeetData { 34 + val content: Post = (post.post.record.decodeAs()) 35 + 36 + return SkeetData( 37 + likes = post.post.likeCount, 38 + reposts = post.post.repostCount, 39 + replies = post.post.replyCount, 40 + uri = post.post.uri, 41 + cid = post.post.cid, 42 + didRepost = post.post.viewer?.repost != null, 43 + didLike = post.post.viewer?.like != null, 44 + authorAvatarURL = post.post.author.avatar?.uri, 45 + authorName = post.post.author.displayName, 46 + authorHandle = post.post.author.handle, 47 + authorLabels = post.post.author.labels, 48 + content = content.text, 49 + embed = post.post.embed, 50 + reason = post.reason, 51 + reply = post.reply 52 + ) 53 + } 54 + 55 + fun fromRecordView(post: RecordViewRecord): SkeetData { 56 + val content: Post = (post.value.decodeAs()) 57 + 58 + val maybeEmbed = post.embeds.firstOrNull() 59 + val embed = when (maybeEmbed) { 60 + is RecordViewRecordEmbedUnion.ExternalView -> PostViewEmbedUnion.ExternalView( 61 + maybeEmbed.value 62 + ) 63 + 64 + is RecordViewRecordEmbedUnion.ImagesView -> PostViewEmbedUnion.ImagesView(maybeEmbed.value) 65 + is RecordViewRecordEmbedUnion.RecordView -> PostViewEmbedUnion.RecordView(maybeEmbed.value) 66 + is RecordViewRecordEmbedUnion.RecordWithMediaView -> PostViewEmbedUnion.RecordWithMediaView( 67 + maybeEmbed.value 68 + ) 69 + 70 + is RecordViewRecordEmbedUnion.Unknown -> PostViewEmbedUnion.Unknown(maybeEmbed.value) 71 + is RecordViewRecordEmbedUnion.VideoView -> PostViewEmbedUnion.VideoView(maybeEmbed.value) 72 + null -> null 73 + } 74 + 75 + return SkeetData( 76 + likes = post.likeCount, 77 + reposts = post.repostCount, 78 + replies = post.replyCount, 79 + uri = post.uri, 80 + cid = post.cid, 81 + didRepost = false, 82 + didLike = false, 83 + authorAvatarURL = post.author.avatar?.uri, 84 + authorName = post.author.displayName, 85 + authorHandle = post.author.handle, 86 + authorLabels = post.author.labels, 87 + content = content.text, 88 + embed = embed, 89 + reason = null, 90 + reply = null 91 + ) 92 + } 93 + } 94 + 95 + fun shareURL(): String { 96 + val u = "https://bsky.app/profile/${this.authorHandle}/post/${ 97 + this.uri.split( 98 + "/" 99 + ).last() 100 + }" 101 + 102 + return u 103 + } 104 + }
+3 -4
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 6 6 import androidx.compose.runtime.setValue 7 7 import androidx.lifecycle.ViewModel 8 8 import androidx.lifecycle.viewModelScope 9 - import app.bsky.feed.FeedViewPost 10 9 import app.bsky.feed.GeneratorView 11 10 import dagger.assisted.Assisted 12 11 import dagger.assisted.AssistedFactory ··· 25 24 val feedName: String = "Following", 26 25 val feedAvatar: String? = null, 27 26 val feeds: List<GeneratorView> = listOf(), 28 - val skeets: List<FeedViewPost> = listOf(), 27 + val skeets: List<SkeetData> = listOf(), 29 28 val isFetchingMoreTimeline: Boolean = false, 30 29 val cursor: String? = null, 31 30 val authenticated: Boolean = false, ··· 75 74 } else { 76 75 uiState.selectedFeed 77 76 } 78 - }(), uiState.cursor).onSuccess { 77 + }(), uiState.cursor).onSuccess { it -> 79 78 uiState = uiState.copy( 80 - skeets = uiState.skeets + it.feed, 79 + skeets = (uiState.skeets + it.feed.map { SkeetData.fromFeedViewPost(it) }).distinctBy { it.cid }, 81 80 cursor = it.cursor, 82 81 isFetchingMoreTimeline = false 83 82 )