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.

*: implement in reply to button

with proper threading!

geesawra 6c5f7197 f11125f9

+118 -53
+31 -7
app/src/main/java/industries/geesawra/jerryno/ComposeView.kt
··· 8 8 import androidx.activity.result.PickVisualMediaRequest 9 9 import androidx.activity.result.contract.ActivityResultContracts 10 10 import androidx.compose.foundation.ScrollState 11 + import androidx.compose.foundation.background 11 12 import androidx.compose.foundation.layout.Arrangement 12 13 import androidx.compose.foundation.layout.Box 13 14 import androidx.compose.foundation.layout.Column ··· 37 38 import androidx.compose.material3.ExperimentalMaterial3Api 38 39 import androidx.compose.material3.Icon 39 40 import androidx.compose.material3.MaterialTheme 41 + import androidx.compose.material3.OutlinedCard 40 42 import androidx.compose.material3.OutlinedTextField 41 43 import androidx.compose.material3.Text 42 44 import androidx.compose.material3.TextButton ··· 52 54 import androidx.compose.ui.Modifier 53 55 import androidx.compose.ui.focus.FocusRequester 54 56 import androidx.compose.ui.focus.focusRequester 57 + import androidx.compose.ui.graphics.Color 55 58 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 56 59 import androidx.compose.ui.text.input.ImeAction 57 60 import androidx.compose.ui.unit.dp 61 + import industries.geesawra.jerryno.datalayer.SkeetData 58 62 import industries.geesawra.jerryno.datalayer.TimelineViewModel 59 63 import kotlinx.coroutines.CoroutineScope 60 64 import kotlinx.coroutines.launch ··· 65 69 context: Context, 66 70 coroutineScope: CoroutineScope, 67 71 timelineViewModel: TimelineViewModel, 72 + inReplyTo: MutableState<SkeetData?>, 68 73 scaffoldState: BottomSheetScaffoldState, 69 74 scrollState: ScrollState 70 75 ) { 71 76 val focusRequester = remember { FocusRequester() } 72 77 val keyboardController = LocalSoftwareKeyboardController.current 73 78 var postText by remember { mutableStateOf("") } 79 + val charCount = remember { mutableIntStateOf(0) } 80 + val wasEdited = remember { mutableStateOf(false) } 74 81 val maxChars = 300 75 82 76 83 val mediaSelected = remember { mutableStateOf(mapOf<Uri, String?>()) } ··· 84 91 keyboardController?.hide() 85 92 // Reset state when sheet is hidden 86 93 postText = "" 94 + charCount.intValue = 0 95 + inReplyTo.value = null 87 96 mediaSelected.value = mapOf() 88 97 mediaSelectedIsVideo.value = false 89 98 ··· 158 167 ) 159 168 } 160 169 161 - val charCount = remember { mutableIntStateOf(0) } 162 - val wasEdited = remember { mutableStateOf(false) } 170 + inReplyTo.value?.let { 171 + OutlinedCard( 172 + modifier = Modifier.padding(8.dp) 173 + ) { 174 + SkeetView( 175 + modifier = Modifier 176 + .padding(8.dp) 177 + .background(Color.Transparent), 178 + skeet = it, 179 + nested = true, 180 + disableEmbeds = true 181 + ) 182 + } 183 + } 163 184 164 185 OutlinedTextField( 165 186 keyboardActions = KeyboardActions( ··· 204 225 coroutineScope, 205 226 maxChars, 206 227 timelineViewModel, 207 - scaffoldState 228 + scaffoldState, 229 + inReplyTo.value 208 230 ) 209 231 210 232 Spacer(modifier = Modifier.height(8.dp)) ··· 245 267 coroutineScope: CoroutineScope, 246 268 maxChars: Int, 247 269 timelineViewModel: TimelineViewModel, 248 - scaffoldState: BottomSheetScaffoldState 270 + scaffoldState: BottomSheetScaffoldState, 271 + inReplyToData: SkeetData? = null 249 272 ) { 250 273 251 274 Row( ··· 279 302 coroutineScope.launch { 280 303 uploadingPost.value = true // Show progress immediately 281 304 timelineViewModel.post( 282 - postText, 283 - if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 305 + content = postText, 306 + images = if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 284 307 .ifEmpty { null } else null, 285 - if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 308 + video = if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 309 + replyRef = inReplyToData?.replyRef(), 286 310 ).onSuccess { 287 311 coroutineScope.launch { 288 312 scaffoldState.bottomSheetState.hide()
+3 -1
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 18 18 import androidx.compose.ui.Alignment 19 19 import androidx.compose.ui.Modifier 20 20 import androidx.compose.ui.unit.dp 21 + import industries.geesawra.jerryno.datalayer.SkeetData 21 22 import industries.geesawra.jerryno.datalayer.TimelineViewModel 22 23 23 24 @Composable ··· 25 26 modifier: Modifier = Modifier, 26 27 viewModel: TimelineViewModel, 27 28 state: LazyListState = rememberLazyListState(), 29 + onReplyTap: (SkeetData) -> Unit = {}, 28 30 doneFirstRefresh: () -> Unit = {} 29 31 ) { 30 32 LaunchedEffect(key1 = viewModel.uiState.skeets.isEmpty()) { ··· 42 44 ) { 43 45 viewModel.uiState.skeets.forEach { skeet -> 44 46 item(key = skeet.cid.cid) { 45 - SkeetView(viewModel = viewModel, skeet = skeet) 47 + SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap) 46 48 } 47 49 } 48 50
+10 -13
app/src/main/java/industries/geesawra/jerryno/SkeetView.kt
··· 48 48 @Composable 49 49 fun SkeetView( 50 50 modifier: Modifier = Modifier, 51 - viewModel: TimelineViewModel?, 51 + viewModel: TimelineViewModel? = null, 52 + onReplyTap: (SkeetData) -> Unit = {}, 52 53 skeet: SkeetData, 53 54 nested: Boolean = false, 54 55 disableEmbeds: Boolean = false 55 56 ) { 56 - val likes = skeet.likes 57 - val reposts = skeet.reposts 58 - val replies = skeet.replies 59 57 val minSize = 55.dp 60 58 61 59 Surface( ··· 97 95 98 96 if (!nested && !disableEmbeds) { 99 97 TimelinePostActionsView( 98 + onReplyTap = onReplyTap, 100 99 modifier = Modifier 101 100 .fillMaxWidth(), 102 101 timelineViewModel = viewModel, 103 - replies = replies, 104 - likes = likes, 105 - reposts = reposts, 106 - postUrl = skeet.shareURL(), 107 - uri = skeet.uri, 108 - cid = skeet.cid, 109 - reposted = skeet.didRepost, 110 - liked = skeet.didLike, 102 + skeet = skeet, 111 103 ) 112 104 113 105 HorizontalDivider( ··· 288 280 val rv = rv.record 289 281 when (rv) { 290 282 is RecordViewRecordUnion.ViewRecord -> { 291 - SkeetView(modifier, null, SkeetData.fromRecordView(rv.value), nested = true) 283 + SkeetView( 284 + modifier = modifier, 285 + viewModel = null, 286 + skeet = SkeetData.fromRecordView(rv.value), 287 + nested = true 288 + ) 292 289 } 293 290 294 291 else -> {}
+16 -20
app/src/main/java/industries/geesawra/jerryno/TimelinePostActionsView.kt
··· 32 32 import androidx.compose.ui.platform.LocalContext 33 33 import androidx.compose.ui.unit.dp 34 34 import androidx.core.net.toUri 35 + import industries.geesawra.jerryno.datalayer.SkeetData 35 36 import industries.geesawra.jerryno.datalayer.TimelineViewModel 36 37 import sh.christian.ozone.api.AtUri 37 - import sh.christian.ozone.api.Cid 38 38 import sh.christian.ozone.api.RKey 39 39 40 40 ··· 80 80 fun TimelinePostActionsView( 81 81 modifier: Modifier = Modifier, 82 82 timelineViewModel: TimelineViewModel?, 83 - reposted: Boolean, 84 - liked: Boolean, 85 - replies: Long?, 86 - likes: Long?, 87 - reposts: Long?, 88 - postUrl: String, 89 - uri: AtUri, 90 - cid: Cid, 83 + onReplyTap: (SkeetData) -> Unit = {}, 84 + skeet: SkeetData 91 85 ) { 92 - val likes = remember { mutableLongStateOf(likes ?: 0) } 93 - val reposts = remember { mutableLongStateOf(reposts ?: 0) } 94 - val replies = remember { mutableLongStateOf(replies ?: 0) } 86 + val likes = remember { mutableLongStateOf(skeet.likes ?: 0) } 87 + val reposts = remember { mutableLongStateOf(skeet.reposts ?: 0) } 88 + val replies = remember { mutableLongStateOf(skeet.replies ?: 0) } 95 89 96 90 97 91 Row( ··· 104 98 val sendIntent: Intent = Intent().apply { 105 99 action = Intent.ACTION_SEND 106 100 type = "text/plain" 107 - putExtra(Intent.EXTRA_TEXT, postUrl) 101 + putExtra(Intent.EXTRA_TEXT, skeet.shareURL()) 108 102 } 109 103 ctx.startActivity( 110 104 Intent.createChooser(sendIntent, "Share Bluesky post") ··· 120 114 } 121 115 122 116 IconButton( 123 - onClick = {} 117 + onClick = { 118 + onReplyTap(skeet) 119 + } 124 120 ) { 125 121 IconWithNumber( 126 122 imageVector = { ··· 142 138 ) 143 139 } 144 140 145 - var isLiked by rememberSaveable { mutableStateOf(liked) } 141 + var isLiked by rememberSaveable { mutableStateOf(skeet.didLike) } 146 142 IconButton( 147 143 onClick = { 148 144 when (isLiked) { 149 - false -> timelineViewModel?.like(uri, cid) { 145 + false -> timelineViewModel?.like(skeet.uri, skeet.cid) { 150 146 isLiked = true 151 147 likes.longValue++ 152 148 } 153 149 154 - true -> timelineViewModel?.deleteLike(cid) { 150 + true -> timelineViewModel?.deleteLike(skeet.cid) { 155 151 isLiked = false 156 152 likes.longValue-- 157 153 } ··· 166 162 ) 167 163 } 168 164 169 - var isReposted by rememberSaveable { mutableStateOf(reposted) } 165 + var isReposted by rememberSaveable { mutableStateOf(skeet.didRepost) } 170 166 IconButton( 171 167 onClick = { 172 168 when (isReposted) { 173 - false -> timelineViewModel?.repost(uri, cid) { 169 + false -> timelineViewModel?.repost(skeet.uri, skeet.cid) { 174 170 isReposted = true 175 171 reposts.longValue++ 176 172 } 177 173 178 - true -> timelineViewModel?.deleteRepost(cid) { 174 + true -> timelineViewModel?.deleteRepost(skeet.cid) { 179 175 isReposted = false 180 176 reposts.longValue-- 181 177 }
+19 -8
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 63 63 import androidx.compose.ui.text.font.FontWeight 64 64 import androidx.compose.ui.unit.dp 65 65 import coil3.compose.AsyncImage 66 + import industries.geesawra.jerryno.datalayer.SkeetData 66 67 import industries.geesawra.jerryno.datalayer.TimelineViewModel 67 68 import kotlinx.coroutines.CoroutineScope 68 69 import kotlinx.coroutines.launch ··· 91 92 ) 92 93 ) 93 94 95 + val inReplyTo = remember { mutableStateOf<SkeetData?>(null) } 96 + 94 97 BottomSheetScaffold( 95 98 modifier = Modifier 96 99 .windowInsetsPadding(WindowInsets.statusBars), 97 100 scaffoldState = scaffoldState, 98 101 sheetPeekHeight = 0.dp, 99 102 sheetDragHandle = { 100 - BottomSheetDefaults.DragHandle( 101 - ) 103 + BottomSheetDefaults.DragHandle() 102 104 }, 103 105 sheetContent = { 104 106 ComposeView( 105 - LocalContext.current, 106 - coroutineScope, 107 - timelineViewModel, 108 - scaffoldState, 109 - scrollState 107 + context = LocalContext.current, 108 + coroutineScope = coroutineScope, 109 + timelineViewModel = timelineViewModel, 110 + scaffoldState = scaffoldState, 111 + scrollState = scrollState, 112 + inReplyTo = inReplyTo 110 113 ) 111 114 }, 112 115 content = { paddingValues -> ··· 119 122 scaffoldState.bottomSheetState.expand() 120 123 } 121 124 }, 125 + onReplyTap = { 126 + inReplyTo.value = it 127 + coroutineScope.launch { 128 + scaffoldState.bottomSheetState.expand() 129 + } 130 + }, 122 131 loginError = onLoginError 123 132 ) 124 133 } ··· 131 140 modifier: Modifier = Modifier, 132 141 coroutineScope: CoroutineScope, 133 142 timelineViewModel: TimelineViewModel, 143 + onReplyTap: (SkeetData) -> Unit = {}, 134 144 fobOnClick: () -> Unit, 135 145 loginError: () -> Unit, 136 146 ) { ··· 273 283 ShowSkeets( 274 284 viewModel = timelineViewModel, 275 285 state = listState, 276 - modifier = Modifier.padding(values) 286 + modifier = Modifier.padding(values), 287 + onReplyTap = onReplyTap 277 288 ) { isRefreshing.value = false } 278 289 } 279 290 }
+9 -2
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 20 20 import app.bsky.feed.Like 21 21 import app.bsky.feed.Post 22 22 import app.bsky.feed.PostEmbedUnion 23 + import app.bsky.feed.PostReplyRef 23 24 import app.bsky.feed.Repost 24 25 import com.atproto.identity.ResolveHandleQueryParams 25 26 import com.atproto.identity.ResolveHandleResponse ··· 410 411 } 411 412 } 412 413 413 - suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 414 + suspend fun post( 415 + content: String, 416 + images: List<Uri>? = null, 417 + video: Uri? = null, 418 + replyRef: PostReplyRef? = null 419 + ): Result<Unit> { 414 420 // TODO: videos need to be uploaded through a different API. 415 421 return runCatching { 416 422 create().onFailure { ··· 447 453 Post( 448 454 text = content, 449 455 createdAt = Clock.System.now(), 450 - embed = postEmbed 456 + embed = postEmbed, 457 + reply = replyRef 451 458 ) 452 459 ) 453 460
+21
app/src/main/java/industries/geesawra/jerryno/datalayer/Models.kt
··· 5 5 import app.bsky.feed.FeedViewPost 6 6 import app.bsky.feed.FeedViewPostReasonUnion 7 7 import app.bsky.feed.Post 8 + import app.bsky.feed.PostReplyRef 8 9 import app.bsky.feed.PostViewEmbedUnion 9 10 import app.bsky.feed.ReplyRef 11 + import app.bsky.feed.ReplyRefRootUnion 10 12 import com.atproto.label.Label 13 + import com.atproto.repo.StrongRef 11 14 import sh.christian.ozone.api.AtUri 12 15 import sh.christian.ozone.api.Cid 13 16 import sh.christian.ozone.api.Handle ··· 90 93 reply = null 91 94 ) 92 95 } 96 + } 97 + 98 + fun replyRef(): PostReplyRef { 99 + val thisPostRef = StrongRef(this.uri, this.cid) 100 + 101 + val maybeRoot = this.reply?.root 102 + val rootRef = when (maybeRoot) { 103 + is ReplyRefRootUnion.BlockedPost -> null 104 + is ReplyRefRootUnion.NotFoundPost -> null 105 + is ReplyRefRootUnion.PostView -> StrongRef(maybeRoot.value.uri, maybeRoot.value.cid) 106 + is ReplyRefRootUnion.Unknown -> null 107 + null -> null 108 + } 109 + 110 + return PostReplyRef( 111 + parent = thisPostRef, 112 + root = rootRef ?: thisPostRef 113 + ) 93 114 } 94 115 95 116 fun shareURL(): String {
+9 -2
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 7 7 import androidx.lifecycle.ViewModel 8 8 import androidx.lifecycle.viewModelScope 9 9 import app.bsky.feed.GeneratorView 10 + import app.bsky.feed.PostReplyRef 10 11 import dagger.assisted.Assisted 11 12 import dagger.assisted.AssistedFactory 12 13 import dagger.assisted.AssistedInject ··· 104 105 ) 105 106 } 106 107 107 - suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 108 + suspend fun post( 109 + content: String, 110 + images: List<Uri>? = null, 111 + video: Uri? = null, 112 + replyRef: PostReplyRef? = null 113 + ): Result<Unit> { 108 114 return bskyConn.post( 109 115 content, 110 116 images, 111 - video 117 + video, 118 + replyRef, 112 119 ) // TODO: maybe refactor this to use uistate.Error? 113 120 } 114 121