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.

TimelineView: working feeds changer

Missing flicker fix

geesawra de76d672 dfb24662

+228 -70
+2 -2
app/src/main/java/industries/geesawra/jerryno/ShowSkeets.kt
··· 60 60 } 61 61 } 62 62 } else { 63 - viewModel.uiState.skeets.forEach { skeet -> 64 - item(key = skeet.post.uri.toString()) { 63 + viewModel.uiState.skeets.distinctBy { it.post.cid }.forEach { skeet -> 64 + item(key = skeet.post.cid.cid) { 65 65 SkeetRowView(skeet) 66 66 } 67 67 }
+21 -21
app/src/main/java/industries/geesawra/jerryno/TimelinePostActionsView.kt
··· 69 69 horizontalArrangement = Arrangement.End, 70 70 modifier = modifier, 71 71 ) { 72 + val ctx = LocalContext.current 73 + IconButton( 74 + onClick = { 75 + val sendIntent: Intent = Intent().apply { 76 + action = Intent.ACTION_SEND 77 + type = "text/plain" 78 + putExtra(Intent.EXTRA_TEXT, uri) 79 + } 80 + ctx.startActivity( 81 + Intent.createChooser(sendIntent, "Share Bluesky post") 82 + ) 83 + } 84 + ) { 85 + Icon( 86 + modifier = Modifier.size(15.dp), 87 + imageVector = Icons.Default.Share, 88 + contentDescription = "Share", 89 + tint = MaterialTheme.colorScheme.onSurfaceVariant 90 + ) 91 + } 92 + 72 93 IconButton( 73 94 onClick = {} 74 95 ) { ··· 106 127 Icons.Default.Repeat, 107 128 contentDescription = "Repost", 108 129 number = reposts, 109 - ) 110 - } 111 - 112 - val ctx = LocalContext.current 113 - IconButton( 114 - onClick = { 115 - val sendIntent: Intent = Intent().apply { 116 - action = Intent.ACTION_SEND 117 - type = "text/plain" 118 - putExtra(Intent.EXTRA_TEXT, uri) 119 - } 120 - ctx.startActivity( 121 - Intent.createChooser(sendIntent, "Share Bluesky post") 122 - ) 123 - } 124 - ) { 125 - Icon( 126 - modifier = Modifier.size(15.dp), 127 - imageVector = Icons.Default.Share, 128 - contentDescription = "Share", 129 - tint = MaterialTheme.colorScheme.onSurfaceVariant 130 130 ) 131 131 } 132 132 }
+138 -15
app/src/main/java/industries/geesawra/jerryno/TimelineView.kt
··· 24 24 import androidx.compose.foundation.layout.size 25 25 import androidx.compose.foundation.layout.windowInsetsPadding 26 26 import androidx.compose.foundation.lazy.rememberLazyListState 27 + import androidx.compose.foundation.shape.CircleShape 27 28 import androidx.compose.foundation.text.KeyboardActions 28 29 import androidx.compose.material.icons.Icons 29 30 import androidx.compose.material.icons.automirrored.filled.Send ··· 31 32 import androidx.compose.material.icons.filled.Create 32 33 import androidx.compose.material.icons.filled.Home 33 34 import androidx.compose.material.icons.filled.Notifications 35 + import androidx.compose.material.icons.filled.Tag 34 36 import androidx.compose.material3.BottomSheetScaffold 35 37 import androidx.compose.material3.Button 36 38 import androidx.compose.material3.ButtonDefaults 37 39 import androidx.compose.material3.Card 38 40 import androidx.compose.material3.CircularProgressIndicator 41 + import androidx.compose.material3.DrawerState 42 + import androidx.compose.material3.DrawerValue 39 43 import androidx.compose.material3.ExperimentalMaterial3Api 40 44 import androidx.compose.material3.FloatingActionButton 41 45 import androidx.compose.material3.HorizontalDivider 42 46 import androidx.compose.material3.Icon 47 + import androidx.compose.material3.IconButton 48 + import androidx.compose.material3.LargeTopAppBar 43 49 import androidx.compose.material3.MaterialTheme 44 - import androidx.compose.material3.MediumTopAppBar 45 50 import androidx.compose.material3.ModalDrawerSheet 46 51 import androidx.compose.material3.ModalNavigationDrawer 47 52 import androidx.compose.material3.NavigationBar 48 53 import androidx.compose.material3.NavigationBarItem 49 54 import androidx.compose.material3.NavigationDrawerItem 55 + import androidx.compose.material3.NavigationDrawerItemDefaults 50 56 import androidx.compose.material3.OutlinedTextField 51 57 import androidx.compose.material3.Scaffold 52 58 import androidx.compose.material3.Text ··· 54 60 import androidx.compose.material3.TopAppBarColors 55 61 import androidx.compose.material3.TopAppBarDefaults 56 62 import androidx.compose.material3.rememberBottomSheetScaffoldState 63 + import androidx.compose.material3.rememberDrawerState 57 64 import androidx.compose.material3.rememberModalBottomSheetState 58 65 import androidx.compose.material3.rememberTopAppBarState 59 66 import androidx.compose.runtime.Composable ··· 66 73 import androidx.compose.runtime.setValue 67 74 import androidx.compose.ui.Alignment 68 75 import androidx.compose.ui.Modifier 76 + import androidx.compose.ui.draw.clip 77 + import androidx.compose.ui.draw.shadow 69 78 import androidx.compose.ui.focus.FocusRequester 70 79 import androidx.compose.ui.focus.focusRequester 71 80 import androidx.compose.ui.graphics.vector.ImageVector ··· 73 82 import androidx.compose.ui.platform.LocalContext 74 83 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 75 84 import androidx.compose.ui.res.stringResource 85 + import androidx.compose.ui.text.font.FontWeight 76 86 import androidx.compose.ui.text.input.ImeAction 77 87 import androidx.compose.ui.unit.dp 88 + import coil3.compose.AsyncImage 78 89 import industries.geesawra.jerryno.datalayer.TimelineViewModel 79 90 import kotlinx.coroutines.CoroutineScope 80 91 import kotlinx.coroutines.launch ··· 327 338 fobOnClick: () -> Unit // Changed to fobOnClick to avoid confusion with FAB acronym 328 339 ) { 329 340 var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.HOME) } 330 - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( 341 + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( 331 342 rememberTopAppBarState() 332 343 ) 333 344 val listState = rememberLazyListState() 345 + val drawerState = rememberDrawerState( 346 + initialValue = DrawerValue.Closed 347 + ) 348 + val ctx = LocalContext.current 349 + 350 + LaunchedEffect(Unit) { 351 + timelineViewModel.feeds() 352 + } 353 + 354 + 355 + LaunchedEffect(timelineViewModel.uiState.error) { 356 + timelineViewModel.uiState.error?.let { 357 + Toast.makeText(ctx, "Error: ${it}", Toast.LENGTH_LONG) 358 + .show() 359 + } 360 + } 334 361 335 362 ModalNavigationDrawer( 363 + drawerState = drawerState, 336 364 modifier = modifier, 337 365 drawerContent = { 338 - ModalDrawerSheet { 339 - Text("Drawer title", modifier = Modifier.padding(16.dp)) 340 - HorizontalDivider() 341 - NavigationDrawerItem( 342 - label = { Text(text = "Drawer Item") }, 343 - selected = false, 344 - onClick = { /*TODO*/ } 345 - ) 346 - } 366 + FeedsDrawer(timelineViewModel, coroutineScope, drawerState) 347 367 } 348 368 ) { 349 369 Scaffold( ··· 352 372 .fillMaxSize() 353 373 .nestedScroll(scrollBehavior.nestedScrollConnection), 354 374 topBar = { 355 - MediumTopAppBar( 375 + LargeTopAppBar( 356 376 colors = TopAppBarColors( 357 377 containerColor = MaterialTheme.colorScheme.background, 358 378 scrolledContainerColor = MaterialTheme.colorScheme.background, 359 379 navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast 360 380 titleContentColor = MaterialTheme.colorScheme.onBackground, 361 - actionIconContentColor = MaterialTheme.colorScheme.onBackground 381 + actionIconContentColor = MaterialTheme.colorScheme.onBackground, 382 + subtitleContentColor = MaterialTheme.colorScheme.onBackground 362 383 ), 363 384 title = { 364 - Text(text = "Jerry No") 385 + Row( 386 + horizontalArrangement = Arrangement.spacedBy(8.dp), 387 + verticalAlignment = Alignment.CenterVertically 388 + ) { 389 + if (timelineViewModel.uiState.feedAvatar != null) { 390 + AsyncImage( 391 + model = timelineViewModel.uiState.feedAvatar, 392 + modifier = Modifier 393 + .size(42.dp) 394 + .shadow(10.dp, CircleShape) 395 + .clip(CircleShape), 396 + contentDescription = "Feed avatar", 397 + ) 398 + } 399 + 400 + Text(text = timelineViewModel.uiState.feedName) 401 + } 365 402 }, 366 403 scrollBehavior = scrollBehavior, 367 404 modifier = Modifier.clickable { 368 405 coroutineScope.launch { 369 406 listState.animateScrollToItem(0) 370 407 } 371 - } 408 + }, 409 + navigationIcon = { 410 + IconButton(onClick = { 411 + coroutineScope.launch { 412 + drawerState.open() 413 + } 414 + }) { 415 + Icon(Icons.Default.Tag, "Feeds") 416 + } 417 + }, 372 418 ) 373 419 }, 374 420 floatingActionButton = { ··· 406 452 } 407 453 } 408 454 } 455 + 456 + @Composable 457 + fun FeedsDrawer( 458 + timelineViewModel: TimelineViewModel, 459 + coroutineScope: CoroutineScope, 460 + drawerState: DrawerState 461 + ) { 462 + val selectFeed = { uri: String, displayName: String, avatar: String? -> 463 + timelineViewModel.selectFeed(uri, displayName, avatar) 464 + coroutineScope.launch { 465 + drawerState.close() 466 + } 467 + } 468 + 469 + ModalDrawerSheet { 470 + Text( 471 + "Feeds", 472 + modifier = Modifier.padding(16.dp), 473 + style = MaterialTheme.typography.titleLarge, 474 + fontWeight = FontWeight.Bold 475 + ) 476 + if (timelineViewModel.uiState.feeds.isEmpty()) { 477 + CircularProgressIndicator() 478 + return@ModalDrawerSheet 479 + } 480 + 481 + NavigationDrawerItem( 482 + label = { 483 + Row( 484 + horizontalArrangement = Arrangement.spacedBy(8.dp), 485 + verticalAlignment = Alignment.CenterVertically 486 + ) { 487 + Spacer(modifier = Modifier.size(20.dp)) 488 + Text(text = "Following") 489 + } 490 + }, 491 + selected = timelineViewModel.uiState.selectedFeed.lowercase() == "following", 492 + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), 493 + onClick = { 494 + selectFeed("following", "Following", null) 495 + } 496 + ) 497 + HorizontalDivider() 498 + 499 + timelineViewModel.uiState.feeds.forEach { 500 + NavigationDrawerItem( 501 + label = { 502 + Row( 503 + horizontalArrangement = Arrangement.spacedBy(8.dp), 504 + verticalAlignment = Alignment.CenterVertically 505 + ) { 506 + if (it.avatar != null) { 507 + AsyncImage( 508 + model = it.avatar?.uri, 509 + modifier = Modifier 510 + .size(20.dp) 511 + .clip(CircleShape), 512 + contentDescription = "Feed avatar", 513 + ) 514 + } else { 515 + Spacer(modifier = Modifier.size(20.dp)) 516 + } 517 + 518 + Text(text = it.displayName) 519 + } 520 + }, 521 + selected = timelineViewModel.uiState.selectedFeed == it.uri.atUri, 522 + modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), 523 + onClick = { 524 + selectFeed(it.uri.atUri, it.displayName, it.avatar?.uri) 525 + } 526 + ) 527 + HorizontalDivider() 528 + } 529 + 530 + } 531 + }
+33 -26
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 8 8 import androidx.datastore.preferences.core.edit 9 9 import androidx.datastore.preferences.core.stringPreferencesKey 10 10 import androidx.datastore.preferences.preferencesDataStore 11 + import app.bsky.actor.PreferencesUnion 11 12 import app.bsky.embed.Images 12 13 import app.bsky.embed.ImagesImage 14 + import app.bsky.feed.GeneratorView 15 + import app.bsky.feed.GetFeedGeneratorsQueryParams 13 16 import app.bsky.feed.GetTimelineQueryParams 14 17 import app.bsky.feed.GetTimelineResponse 15 18 import app.bsky.feed.Post ··· 39 42 import kotlinx.serialization.Serializable 40 43 import sh.christian.ozone.BlueskyJson 41 44 import sh.christian.ozone.XrpcBlueskyApi 45 + import sh.christian.ozone.api.AtUri 42 46 import sh.christian.ozone.api.AuthenticatedXrpcBlueskyApi 43 47 import sh.christian.ozone.api.BlueskyAuthPlugin 44 48 import sh.christian.ozone.api.Did ··· 307 311 } 308 312 } 309 313 310 - suspend fun fetchTimeline(cursor: String? = null): Result<GetTimelineResponse> { 314 + suspend fun fetchTimeline( 315 + algorithm: String, 316 + cursor: String? = null 317 + ): Result<GetTimelineResponse> { 311 318 return runCatching { 312 319 create().getOrThrow() 313 320 val timeline = client!!.getTimeline( 314 321 GetTimelineQueryParams( 322 + algorithm = algorithm, 315 323 limit = 25, 316 324 cursor = cursor 317 325 ) ··· 325 333 } 326 334 327 335 return Result.success(feed) 328 - } 329 - } 330 - 331 - suspend fun post(content: String): Result<Unit> { 332 - return runCatching { 333 - create().getOrThrow() 334 - 335 - val r = BlueskyJson.encodeAsJsonContent( 336 - Post( 337 - text = content, 338 - createdAt = Clock.System.now(), 339 - ) 340 - ) 341 - 342 - val postRes = client!!.createRecord( 343 - CreateRecordRequest( 344 - repo = session!!.handle, // Use handle from the session 345 - collection = Nsid("app.bsky.feed.post"), 346 - record = r, 347 - ) 348 - ) 349 - return when (postRes) { 350 - is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 351 - is AtpResponse.Success<*> -> Result.success(Unit) 352 - } 353 336 } 354 337 } 355 338 ··· 440 423 441 424 442 425 return Result.success(uploadedBlobs) 426 + } 427 + } 428 + 429 + suspend fun feeds(): Result<List<GeneratorView>> { 430 + return runCatching { 431 + create().getOrThrow() 432 + 433 + val prefs = client!!.getPreferences().requireResponse() 434 + val feedUris = (prefs.preferences.first { 435 + when (it) { 436 + is PreferencesUnion.SavedFeedsPrefV2 -> true 437 + else -> false 438 + } 439 + } as PreferencesUnion.SavedFeedsPrefV2).value.items.filter { 440 + it.type.value != "timeline" 441 + }.map { AtUri(it.value) } 442 + 443 + val resp = client!!.getFeedGenerators( 444 + GetFeedGeneratorsQueryParams( 445 + feedUris 446 + ) 447 + ).requireResponse() 448 + 449 + return Result.success(resp.feeds) 443 450 } 444 451 } 445 452 }
+34 -6
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 8 8 import androidx.lifecycle.ViewModel 9 9 import androidx.lifecycle.viewModelScope 10 10 import app.bsky.feed.FeedViewPost 11 + import app.bsky.feed.GeneratorView 11 12 import dagger.assisted.Assisted 12 13 import dagger.assisted.AssistedFactory 13 14 import dagger.assisted.AssistedInject ··· 17 18 18 19 19 20 data class TimelineUiState( 21 + val selectedFeed: String = "Following", 22 + val feedName: String = "Following", 23 + val feedAvatar: String? = null, 24 + val feeds: List<GeneratorView> = listOf(), 20 25 val skeets: List<FeedViewPost> = listOf(), 21 26 val isFetchingMoreTimeline: Boolean = false, 22 27 val cursor: String? = null, 23 28 val authenticated: Boolean = false, 24 29 val sessionChecked: Boolean = false, 25 - val authError: String = "", 26 - val postError: String? = null 30 + 31 + val error: String? = null 27 32 ) 28 33 29 34 @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) ··· 64 69 fetchJob?.cancel() 65 70 66 71 fetchJob = viewModelScope.launch { 67 - bskyConn.fetchTimeline(uiState.cursor).onSuccess { 68 - Log.d("TimelineViewModel", "New cursor ${it.cursor}") 72 + bskyConn.fetchTimeline({ 73 + if (uiState.selectedFeed == "Following") { 74 + "" 75 + } else { 76 + uiState.selectedFeed 77 + } 78 + }(), uiState.cursor).onSuccess { 69 79 uiState = uiState.copy( 70 80 skeets = uiState.skeets + it.feed, 71 81 cursor = it.cursor, ··· 85 95 } 86 96 87 97 suspend fun post(content: String, images: List<Uri>? = null, video: Uri? = null): Result<Unit> { 88 - return bskyConn.post(content, images, video).onFailure { 89 - uiState = uiState.copy(postError = it.message) 98 + return bskyConn.post(content, images, video) 99 + } 100 + 101 + fun feeds() { 102 + viewModelScope.launch { 103 + bskyConn.feeds().onFailure { 104 + uiState = uiState.copy(error = it.message) 105 + }.onSuccess { 106 + uiState = uiState.copy(feeds = it) 107 + } 90 108 } 109 + } 110 + 111 + fun selectFeed(uri: String, displayName: String, avatar: String?) { 112 + uiState = uiState.copy( 113 + selectedFeed = uri, 114 + feedName = displayName, 115 + feedAvatar = avatar, 116 + ) 117 + reset() 118 + fetchTimeline() 91 119 } 92 120 }