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.

SkeetView: threading view, including indicator

Need to implement "see thread" detection.

geesawra 88f0ce68 aaaa82a2

+170 -34
+2 -2
.idea/deploymentTargetSelector.xml
··· 4 4 <selectionStates> 5 5 <SelectionState runConfigName="app"> 6 6 <option name="selectionMode" value="DROPDOWN" /> 7 - <DropdownSelection timestamp="2025-10-02T10:56:49.580061Z"> 7 + <DropdownSelection timestamp="2025-10-02T20:10:03.298201200Z"> 8 8 <Target type="DEFAULT_BOOT"> 9 9 <handle> 10 - <DeviceId pluginId="LocalEmulator" identifier="path=/Users/gsora/.android/avd/Medium_Phone.avd" /> 10 + <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\micro\.android\avd\Medium_Phone.avd" /> 11 11 </handle> 12 12 </Target> 13 13 </DropdownSelection>
+1
.idea/dictionaries/project.xml
··· 2 2 <dictionary name="project"> 3 3 <words> 4 4 <w>bsky</w> 5 + <w>rkey</w> 5 6 <w>skeets</w> 6 7 <w>xrpc</w> 7 8 </words>
+17 -1
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 43 43 verticalArrangement = Arrangement.spacedBy(8.dp), 44 44 ) { 45 45 viewModel.uiState.skeets.forEach { skeet -> 46 - item(key = skeet.cid.cid) { 46 + item(key = skeet.key()) { 47 + skeet.root()?.let { 48 + SkeetView( 49 + viewModel = viewModel, 50 + skeet = it, 51 + onReplyTap = onReplyTap, 52 + inThread = true 53 + ) 54 + } 55 + skeet.parent()?.let { 56 + SkeetView( 57 + viewModel = viewModel, 58 + skeet = it, 59 + onReplyTap = onReplyTap, 60 + inThread = true 61 + ) 62 + } 47 63 SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 48 64 } 49 65 }
+18 -7
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 52 52 onReplyTap: (SkeetData) -> Unit = {}, 53 53 skeet: SkeetData, 54 54 nested: Boolean = false, 55 - disableEmbeds: Boolean = false 55 + disableEmbeds: Boolean = false, 56 + inThread: Boolean = false, 56 57 ) { 57 58 val minSize = 55.dp 58 59 60 + val hasParent = skeet.parent() != null 61 + 59 62 Surface( 60 63 color = MaterialTheme.colorScheme.surface, 61 - modifier = modifier.padding(start = 16.dp, end = 16.dp) 64 + modifier = if (!inThread && !hasParent) { 65 + modifier.padding(start = 16.dp, end = 16.dp) 66 + } else { 67 + modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp) 68 + } 62 69 ) { 63 70 Row( 64 71 verticalAlignment = Alignment.Top, ··· 97 104 TimelinePostActionsView( 98 105 onReplyTap = onReplyTap, 99 106 modifier = Modifier 107 + .height(50.dp) 100 108 .fillMaxWidth(), 101 109 timelineViewModel = viewModel, 102 110 skeet = skeet, 111 + inThread = inThread 103 112 ) 113 + } 104 114 105 - HorizontalDivider( 106 - color = MaterialTheme.colorScheme.outlineVariant 107 - ) 108 - } 109 115 } 110 116 111 117 } ··· 267 273 } 268 274 } 269 275 276 + is PostViewEmbedUnion.RecordWithMediaView -> run { 277 + // TODO: map this 278 + // probably better to wrap this thing in a function that we can call recursively 279 + } 280 + 270 281 else -> {} 271 282 } 272 283 ··· 294 305 295 306 @Composable 296 307 private fun SkeetHeader(skeet: SkeetData, modifier: Modifier = Modifier) { 297 - val authorName = skeet.authorName ?: skeet.authorHandle.handle 308 + val authorName = skeet.authorName ?: (skeet.authorHandle?.handle ?: "") 298 309 299 310 Column(modifier = modifier) { 300 311 skeet.reason?.let {
+26 -3
app/src/main/java/industries/geesawra/monarch/TimelinePostActionsView.kt
··· 3 3 import android.content.Intent 4 4 import androidx.compose.foundation.layout.Arrangement 5 5 import androidx.compose.foundation.layout.Row 6 + import androidx.compose.foundation.layout.Spacer 6 7 import androidx.compose.foundation.layout.padding 7 8 import androidx.compose.foundation.layout.size 9 + import androidx.compose.foundation.shape.RoundedCornerShape 8 10 import androidx.compose.material.icons.Icons 9 11 import androidx.compose.material.icons.automirrored.filled.Reply 10 12 import androidx.compose.material.icons.automirrored.filled.ReplyAll ··· 13 15 import androidx.compose.material.icons.filled.Share 14 16 import androidx.compose.material.icons.filled.ThumbUp 15 17 import androidx.compose.material.icons.filled.ThumbUpOffAlt 18 + import androidx.compose.material3.HorizontalDivider 16 19 import androidx.compose.material3.Icon 17 20 import androidx.compose.material3.IconButton 18 21 import androidx.compose.material3.MaterialTheme 19 22 import androidx.compose.material3.Text 23 + import androidx.compose.material3.VerticalDivider 20 24 import androidx.compose.runtime.Composable 21 25 import androidx.compose.runtime.MutableLongState 22 26 import androidx.compose.runtime.getValue ··· 27 31 import androidx.compose.runtime.setValue 28 32 import androidx.compose.ui.Alignment 29 33 import androidx.compose.ui.Modifier 34 + import androidx.compose.ui.draw.clip 30 35 import androidx.compose.ui.graphics.Color 31 36 import androidx.compose.ui.graphics.vector.ImageVector 32 37 import androidx.compose.ui.platform.LocalContext ··· 81 86 modifier: Modifier = Modifier, 82 87 timelineViewModel: TimelineViewModel?, 83 88 onReplyTap: (SkeetData) -> Unit = {}, 84 - skeet: SkeetData 89 + skeet: SkeetData, 90 + inThread: Boolean = false, 85 91 ) { 86 92 val likes = remember { mutableLongStateOf(skeet.likes ?: 0) } 87 93 val reposts = remember { mutableLongStateOf(skeet.reposts ?: 0) } 88 94 val replies = remember { mutableLongStateOf(skeet.replies ?: 0) } 89 95 90 - 91 96 Row( 92 97 horizontalArrangement = Arrangement.End, 93 98 modifier = modifier, 94 99 ) { 95 100 val ctx = LocalContext.current 101 + 102 + if (inThread) { 103 + VerticalDivider( 104 + thickness = 4.dp, 105 + modifier = Modifier 106 + .padding(start = 25.dp, top = 8.dp) 107 + .clip(RoundedCornerShape(12.dp)) 108 + ) 109 + 110 + Spacer(Modifier.weight(1f)) 111 + } 112 + 96 113 IconButton( 97 114 onClick = { 98 115 val sendIntent: Intent = Intent().apply { ··· 183 200 if (isReposted) Icons.Default.RepeatOn else Icons.Default.Repeat, 184 201 contentDescription = "Repost", 185 202 number = reposts, 186 - if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 203 + if (isReposted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant 187 204 ) 188 205 } 206 + } 207 + 208 + if (!inThread) { 209 + HorizontalDivider( 210 + color = MaterialTheme.colorScheme.outlineVariant 211 + ) 189 212 } 190 213 }
+4 -4
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 158 158 159 159 val body: String = rawDoc.body() 160 160 161 - val solvedDoc: didDoc = BlueskyJson.decodeFromString(didDoc.serializer(), body) 161 + val solvedDoc: DIDDoc = BlueskyJson.decodeFromString(DIDDoc.serializer(), body) 162 162 163 163 for (ps in solvedDoc.service) { 164 164 if (ps.id == "#atproto_pds" && ps.type == "AtprotoPersonalDataServer") { ··· 172 172 } 173 173 174 174 @Serializable 175 - private data class service( 175 + private data class Service( 176 176 val id: String, 177 177 val type: String, 178 178 val serviceEndpoint: String 179 179 ) 180 180 181 181 @Serializable 182 - private data class didDoc( 183 - val service: List<service> 182 + private data class DIDDoc( 183 + val service: List<Service> 184 184 ) 185 185 186 186 var client: AuthenticatedXrpcBlueskyApi? = null
+102 -17
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 6 6 import app.bsky.feed.FeedViewPostReasonUnion 7 7 import app.bsky.feed.Post 8 8 import app.bsky.feed.PostReplyRef 9 + import app.bsky.feed.PostView 9 10 import app.bsky.feed.PostViewEmbedUnion 10 11 import app.bsky.feed.ReplyRef 12 + import app.bsky.feed.ReplyRefParentUnion 11 13 import app.bsky.feed.ReplyRefRootUnion 12 14 import com.atproto.label.Label 13 15 import com.atproto.repo.StrongRef 14 16 import sh.christian.ozone.api.AtUri 15 17 import sh.christian.ozone.api.Cid 16 18 import sh.christian.ozone.api.Handle 19 + import sh.christian.ozone.api.model.Timestamp 17 20 18 21 data class SkeetData( 19 - val likes: Long?, 20 - val reposts: Long?, 21 - val replies: Long?, 22 - val uri: AtUri, 23 - val cid: Cid, 24 - val didRepost: Boolean, 25 - val didLike: Boolean, 26 - val authorAvatarURL: String?, 27 - val authorName: String?, 28 - val authorHandle: Handle, 29 - val authorLabels: List<Label>, 30 - val content: String, 31 - val embed: PostViewEmbedUnion?, 32 - val reason: FeedViewPostReasonUnion?, 33 - val reply: ReplyRef?, 22 + val likes: Long? = null, 23 + val reposts: Long? = null, 24 + val replies: Long? = null, 25 + val uri: AtUri = AtUri(""), 26 + val cid: Cid = Cid(""), 27 + val didRepost: Boolean = false, 28 + val didLike: Boolean = false, 29 + val authorAvatarURL: String? = null, 30 + val authorName: String? = null, 31 + val authorHandle: Handle? = null, 32 + val authorLabels: List<Label> = listOf(), 33 + val content: String = "", 34 + val embed: PostViewEmbedUnion? = null, 35 + val reason: FeedViewPostReasonUnion? = null, 36 + val reply: ReplyRef? = null, 37 + val createdAt: Timestamp? = null, 38 + 39 + val blocked: Boolean = false, 40 + val notFound: Boolean = false, 34 41 ) { 35 42 companion object { 36 43 fun fromFeedViewPost(post: FeedViewPost): SkeetData { ··· 51 58 content = content.text, 52 59 embed = post.post.embed, 53 60 reason = post.reason, 54 - reply = post.reply 61 + reply = post.reply, 62 + createdAt = content.createdAt 63 + ) 64 + } 65 + 66 + fun fromPostView(post: PostView): SkeetData { 67 + val content: Post = (post.record.decodeAs()) 68 + 69 + return SkeetData( 70 + likes = post.likeCount, 71 + reposts = post.repostCount, 72 + replies = post.replyCount, 73 + uri = post.uri, 74 + cid = post.cid, 75 + didRepost = post.viewer?.repost != null, 76 + didLike = post.viewer?.like != null, 77 + authorAvatarURL = post.author.avatar?.uri, 78 + authorName = post.author.displayName, 79 + authorHandle = post.author.handle, 80 + authorLabels = post.author.labels, 81 + content = content.text, 82 + embed = post.embed, 83 + createdAt = content.createdAt 55 84 ) 56 85 } 57 86 ··· 90 119 content = content.text, 91 120 embed = embed, 92 121 reason = null, 93 - reply = null 122 + reply = null, 123 + createdAt = content.createdAt 94 124 ) 95 125 } 96 126 } ··· 111 141 parent = thisPostRef, 112 142 root = rootRef ?: thisPostRef 113 143 ) 144 + } 145 + 146 + fun parent(): SkeetData? { 147 + val rawParent = this.reply?.parent 148 + return when (rawParent) { 149 + is ReplyRefParentUnion.BlockedPost -> SkeetData( 150 + authorName = "Blocked", 151 + uri = rawParent.value.uri, 152 + blocked = rawParent.value.blocked 153 + ) 154 + 155 + is ReplyRefParentUnion.NotFoundPost -> SkeetData( 156 + authorName = "Post not found", 157 + uri = rawParent.value.uri, 158 + notFound = rawParent.value.notFound 159 + ) 160 + 161 + is ReplyRefParentUnion.PostView -> fromPostView(rawParent.value) 162 + 163 + else -> null 164 + } 165 + } 166 + 167 + fun root(): SkeetData? { 168 + val p = this.parent() 169 + 170 + val rawRoot = this.reply?.root 171 + val r = when (rawRoot) { 172 + is ReplyRefRootUnion.BlockedPost -> SkeetData( 173 + uri = rawRoot.value.uri, 174 + blocked = rawRoot.value.blocked 175 + ) 176 + 177 + is ReplyRefRootUnion.NotFoundPost -> SkeetData( 178 + uri = rawRoot.value.uri, 179 + notFound = rawRoot.value.notFound 180 + ) 181 + 182 + is ReplyRefRootUnion.PostView -> fromPostView(rawRoot.value) 183 + 184 + else -> null 185 + } 186 + 187 + if (r?.cid == p?.cid) { 188 + return null 189 + } 190 + 191 + return r 192 + } 193 + 194 + // TODO: detect if thread is made of more than the posts we have, 195 + // if so, show a (more) button to load the thread. 196 + 197 + fun key(): String { 198 + return this.uri.split("/").last() 114 199 } 115 200 116 201 fun shareURL(): String {