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.

TimelineViewModel: store likes/reposts based on their cid, so that users can remove them if needed

geesawra 0dacf7cb a2aca0a0

+116 -20
+32 -10
app/src/main/java/industries/geesawra/jerryno/TimelinePostActionsView.kt
··· 18 18 import androidx.compose.material3.MaterialTheme 19 19 import androidx.compose.material3.Text 20 20 import androidx.compose.runtime.Composable 21 + import androidx.compose.runtime.MutableLongState 21 22 import androidx.compose.runtime.getValue 23 + import androidx.compose.runtime.mutableLongStateOf 22 24 import androidx.compose.runtime.mutableStateOf 23 25 import androidx.compose.runtime.remember 24 26 import androidx.compose.runtime.saveable.rememberSaveable ··· 29 31 import androidx.compose.ui.graphics.vector.ImageVector 30 32 import androidx.compose.ui.platform.LocalContext 31 33 import androidx.compose.ui.unit.dp 34 + import androidx.core.net.toUri 32 35 import industries.geesawra.jerryno.datalayer.TimelineViewModel 33 36 import sh.christian.ozone.api.AtUri 34 37 import sh.christian.ozone.api.Cid 38 + import sh.christian.ozone.api.RKey 35 39 36 40 37 41 @Composable 38 42 private fun IconWithNumber( 39 43 imageVector: ImageVector, 40 44 contentDescription: String, 41 - number: Long?, 45 + number: MutableLongState, 42 46 tint: Color 43 47 ) { 44 48 Row( ··· 56 60 ) 57 61 Text( 58 62 modifier = Modifier.padding(start = 2.dp), 59 - text = (number ?: 0).toString(), 63 + text = number.longValue.toString(), 60 64 color = tint, 61 65 maxLines = 1, 62 66 onTextLayout = { textLayout -> ··· 68 72 } 69 73 } 70 74 75 + fun AtUri.rkey(): RKey { 76 + return RKey(this.atUri.toUri().lastPathSegment!!) 77 + } 78 + 71 79 @Composable 72 80 fun TimelinePostActionsView( 73 81 modifier: Modifier = Modifier, ··· 81 89 uri: AtUri, 82 90 cid: Cid, 83 91 ) { 92 + val likes = remember { mutableLongStateOf(likes ?: 0) } 93 + val reposts = remember { mutableLongStateOf(reposts ?: 0) } 94 + val replies = remember { mutableLongStateOf(replies ?: 0) } 95 + 84 96 85 97 Row( 86 98 horizontalArrangement = Arrangement.End, ··· 112 124 ) { 113 125 IconWithNumber( 114 126 imageVector = { 115 - if (replies == null) { 127 + if (replies.longValue == 0.toLong()) { 116 128 Icons.AutoMirrored.Filled.Reply 117 129 } 118 130 119 - val r = replies!! 120 - if (r > 0) { 131 + val r = replies 132 + if (r.longValue > 0) { 121 133 Icons.AutoMirrored.Filled.ReplyAll 122 134 } else { 123 135 Icons.AutoMirrored.Filled.Reply ··· 133 145 var isLiked by rememberSaveable { mutableStateOf(liked) } 134 146 IconButton( 135 147 onClick = { 136 - timelineViewModel.like(uri, cid) { 137 - isLiked = true 138 - likes?.inc() 148 + when (isLiked) { 149 + false -> timelineViewModel.like(uri, cid) { 150 + isLiked = true 151 + likes.longValue++ 152 + } 153 + 154 + true -> timelineViewModel.deleteLike(cid) { 155 + isLiked = false 156 + likes.longValue-- 157 + } 139 158 } 140 159 } 141 160 ) { ··· 153 172 when (isReposted) { 154 173 false -> timelineViewModel.repost(uri, cid) { 155 174 isReposted = true 156 - reposts?.inc() 175 + reposts.longValue++ 157 176 } 158 177 159 - true -> {} 178 + true -> timelineViewModel.deleteRepost(cid) { 179 + isReposted = false 180 + reposts.longValue-- 181 + } 160 182 } 161 183 162 184 }
+42 -3
app/src/main/java/industries/geesawra/jerryno/datalayer/Bluesky.kt
··· 5 5 import android.util.Log 6 6 import androidx.compose.ui.text.intl.Locale 7 7 import androidx.compose.ui.text.toLowerCase 8 + import androidx.core.net.toUri 8 9 import androidx.datastore.preferences.core.edit 9 10 import androidx.datastore.preferences.core.stringPreferencesKey 10 11 import androidx.datastore.preferences.preferencesDataStore ··· 23 24 import com.atproto.identity.ResolveHandleQueryParams 24 25 import com.atproto.identity.ResolveHandleResponse 25 26 import com.atproto.repo.CreateRecordRequest 27 + import com.atproto.repo.CreateRecordResponse 28 + import com.atproto.repo.DeleteRecordRequest 26 29 import com.atproto.repo.StrongRef 27 30 import com.atproto.repo.UploadBlobResponse 28 31 import com.atproto.server.CreateSessionRequest 29 32 import com.atproto.server.CreateSessionResponse 30 33 import com.atproto.server.GetSessionResponse 31 34 import com.atproto.server.RefreshSessionResponse 35 + import industries.geesawra.jerryno.rkey 32 36 import io.ktor.client.HttpClient 33 37 import io.ktor.client.call.body 34 38 import io.ktor.client.engine.okhttp.OkHttp ··· 54 58 import sh.christian.ozone.api.Did 55 59 import sh.christian.ozone.api.Handle 56 60 import sh.christian.ozone.api.Nsid 61 + import sh.christian.ozone.api.RKey 57 62 import sh.christian.ozone.api.model.Blob 58 63 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 59 64 import sh.christian.ozone.api.response.AtpResponse ··· 544 549 } 545 550 } 546 551 547 - suspend fun like(uri: AtUri, cid: Cid): Result<Unit> { 552 + suspend fun like(uri: AtUri, cid: Cid): Result<RKey> { 548 553 return runCatching { 549 554 create().onFailure { 550 555 return Result.failure(LoginException(it.message)) ··· 568 573 569 574 return when (likeRes) { 570 575 is AtpResponse.Failure<*> -> Result.failure(Exception("Could not like post: ${likeRes.error?.message}")) 571 - is AtpResponse.Success<*> -> Result.success(Unit) 576 + is AtpResponse.Success<CreateRecordResponse> -> Result.success( 577 + RKey(likeRes.response.uri.atUri.toUri().lastPathSegment!!) 578 + ) 572 579 } 573 580 } 574 581 } 575 582 576 - suspend fun repost(uri: AtUri, cid: Cid): Result<Unit> { 583 + suspend fun repost(uri: AtUri, cid: Cid): Result<RKey> { 577 584 return runCatching { 578 585 create().onFailure { 579 586 return Result.failure(LoginException(it.message)) ··· 597 604 598 605 return when (likeRes) { 599 606 is AtpResponse.Failure<*> -> Result.failure(Exception("Could not repost: ${likeRes.error?.message}")) 607 + is AtpResponse.Success<CreateRecordResponse> -> Result.success( 608 + likeRes.response.uri.rkey() 609 + ) 610 + } 611 + } 612 + } 613 + 614 + suspend fun deleteLike(rKey: RKey): Result<Unit> { 615 + return deleteRecord(rKey, "app.bsky.feed.like") 616 + } 617 + 618 + suspend fun deleteRepost(rKey: RKey): Result<Unit> { 619 + return deleteRecord(rKey, "app.bsky.feed.repost") 620 + } 621 + 622 + private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> { 623 + return runCatching { 624 + create().onFailure { 625 + return Result.failure(LoginException(it.message)) 626 + } 627 + 628 + val delRes = client!!.deleteRecord( 629 + DeleteRecordRequest( 630 + repo = session!!.handle, 631 + collection = Nsid(collection), 632 + rkey = rKey, 633 + ) 634 + ) 635 + 636 + return when (delRes) { 637 + is AtpResponse.Failure<*> -> Result.failure(Exception("Could not delete record: ${delRes.error?.message}")) 600 638 is AtpResponse.Success<*> -> Result.success(Unit) 601 639 } 640 + 602 641 } 603 642 } 604 643 }
+42 -7
app/src/main/java/industries/geesawra/jerryno/datalayer/TimelineViewModel.kt
··· 16 16 import kotlinx.coroutines.launch 17 17 import sh.christian.ozone.api.AtUri 18 18 import sh.christian.ozone.api.Cid 19 + import sh.christian.ozone.api.RKey 19 20 20 21 21 22 data class TimelineUiState( ··· 28 29 val cursor: String? = null, 29 30 val authenticated: Boolean = false, 30 31 val sessionChecked: Boolean = false, 32 + 33 + val cidInteractedWith: Map<Cid, RKey> = mapOf(), 31 34 32 35 val loginError: String? = null, 33 - val error: String? = null 36 + val error: String? = null, 34 37 ) 35 38 36 39 @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) ··· 47 50 private set 48 51 49 52 private var fetchJob: Job? = null 50 - 51 - fun create() { 52 - viewModelScope.launch { 53 - bskyConn.create() 54 - } 55 - } 56 53 57 54 fun loadSession() { 58 55 viewModelScope.launch { ··· 137 134 else -> uiState.copy(error = it.message) 138 135 } 139 136 }.onSuccess { 137 + uiState = uiState.copy( 138 + cidInteractedWith = uiState.cidInteractedWith.plus(cid to it) 139 + ) 140 140 then() 141 141 } 142 142 } ··· 150 150 else -> uiState.copy(error = it.message) 151 151 } 152 152 }.onSuccess { 153 + uiState = uiState.copy( 154 + cidInteractedWith = uiState.cidInteractedWith.plus(cid to it) 155 + ) 156 + then() 157 + } 158 + } 159 + } 160 + 161 + fun deleteLike(cid: Cid, then: () -> Unit) { 162 + viewModelScope.launch { 163 + bskyConn.deleteLike(uiState.cidInteractedWith[cid]!!).onFailure { 164 + uiState = when (it) { 165 + is LoginException -> uiState.copy(loginError = it.message) 166 + else -> uiState.copy(error = it.message) 167 + } 168 + }.onSuccess { 169 + uiState = uiState.copy( 170 + cidInteractedWith = uiState.cidInteractedWith.minus(cid) 171 + ) 172 + then() 173 + } 174 + } 175 + } 176 + 177 + fun deleteRepost(cid: Cid, then: () -> Unit) { 178 + viewModelScope.launch { 179 + bskyConn.deleteRepost(uiState.cidInteractedWith[cid]!!).onFailure { 180 + uiState = when (it) { 181 + is LoginException -> uiState.copy(loginError = it.message) 182 + else -> uiState.copy(error = it.message) 183 + } 184 + }.onSuccess { 185 + uiState = uiState.copy( 186 + cidInteractedWith = uiState.cidInteractedWith.minus(cid) 187 + ) 153 188 then() 154 189 } 155 190 }