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.

*: standard quote posts implemented

geesawra a3c69ea1 7b7941ee

+109 -34
+24 -3
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 64 64 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 65 65 import androidx.compose.ui.unit.dp 66 66 import androidx.media3.common.MimeTypes 67 + import com.atproto.repo.StrongRef 67 68 import industries.geesawra.monarch.datalayer.SkeetData 68 69 import industries.geesawra.monarch.datalayer.TimelineViewModel 69 70 import io.sanghun.compose.video.RepeatMode ··· 80 81 coroutineScope: CoroutineScope, 81 82 timelineViewModel: TimelineViewModel, 82 83 inReplyTo: MutableState<SkeetData?>, 84 + isQuotePost: MutableState<Boolean>, 83 85 scaffoldState: BottomSheetScaffoldState, 84 86 scrollState: ScrollState 85 87 ) { ··· 104 106 composeFieldState.clearText() 105 107 charCount.intValue = 0 106 108 inReplyTo.value = null 109 + isQuotePost.value = false 107 110 mediaSelected.value = mapOf() 108 111 mediaSelectedIsVideo.value = false 109 112 ··· 253 256 maxChars, 254 257 timelineViewModel, 255 258 scaffoldState, 256 - inReplyTo.value 259 + inReplyTo.value, 260 + isQuotePost.value 257 261 ) 258 262 259 263 Spacer(modifier = Modifier.height(8.dp)) ··· 331 335 maxChars: Int, 332 336 timelineViewModel: TimelineViewModel, 333 337 scaffoldState: BottomSheetScaffoldState, 334 - inReplyToData: SkeetData? = null 338 + inReplyToData: SkeetData? = null, 339 + isQuotePost: Boolean = false, 335 340 ) { 336 341 337 342 Row( ··· 369 374 images = if (!mediaSelectedIsVideo.value) mediaSelected.value.keys.toList() 370 375 .ifEmpty { null } else null, 371 376 video = if (mediaSelectedIsVideo.value) mediaSelected.value.keys.firstOrNull() else null, 372 - replyRef = inReplyToData?.replyRef(), 377 + replyRef = if (!isQuotePost) { 378 + inReplyToData?.replyRef() 379 + } else { 380 + null 381 + }, 382 + quotePostRef = if (isQuotePost) { 383 + val cid = inReplyToData?.cid 384 + val uri = inReplyToData?.uri 385 + 386 + if (cid == null || uri == null) { 387 + null 388 + } else { 389 + StrongRef(uri, cid) 390 + } 391 + } else { 392 + null 393 + } 373 394 ).onSuccess { 374 395 coroutineScope.launch { 375 396 scaffoldState.bottomSheetState.hide()
+1 -1
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 34 34 modifier: Modifier = Modifier, 35 35 viewModel: TimelineViewModel, 36 36 state: LazyListState = rememberLazyListState(), 37 - onReplyTap: (SkeetData) -> Unit = {}, 37 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 38 38 doneFirstRefresh: () -> Unit = {} 39 39 ) { 40 40 LaunchedEffect(key1 = viewModel.uiState.skeets.isEmpty()) {
+1 -1
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 49 49 fun SkeetView( 50 50 modifier: Modifier = Modifier, 51 51 viewModel: TimelineViewModel? = null, 52 - onReplyTap: (SkeetData) -> Unit = {}, 52 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 53 53 skeet: SkeetData, 54 54 nested: Boolean = false, 55 55 disableEmbeds: Boolean = false,
+43 -4
app/src/main/java/industries/geesawra/monarch/TimelinePostActionsView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 3 import android.content.Intent 4 + import androidx.compose.foundation.interaction.MutableInteractionSource 5 + import androidx.compose.foundation.interaction.collectIsPressedAsState 4 6 import androidx.compose.foundation.layout.Arrangement 5 7 import androidx.compose.foundation.layout.Row 6 8 import androidx.compose.foundation.layout.Spacer ··· 22 24 import androidx.compose.material3.Text 23 25 import androidx.compose.material3.VerticalDivider 24 26 import androidx.compose.runtime.Composable 27 + import androidx.compose.runtime.LaunchedEffect 25 28 import androidx.compose.runtime.MutableLongState 26 29 import androidx.compose.runtime.getValue 27 30 import androidx.compose.runtime.mutableLongStateOf 28 31 import androidx.compose.runtime.mutableStateOf 29 32 import androidx.compose.runtime.remember 33 + import androidx.compose.runtime.rememberUpdatedState 30 34 import androidx.compose.runtime.saveable.rememberSaveable 31 35 import androidx.compose.runtime.setValue 32 36 import androidx.compose.ui.Alignment ··· 39 43 import androidx.core.net.toUri 40 44 import industries.geesawra.monarch.datalayer.SkeetData 41 45 import industries.geesawra.monarch.datalayer.TimelineViewModel 46 + import kotlinx.coroutines.delay 42 47 import sh.christian.ozone.api.AtUri 43 48 import sh.christian.ozone.api.RKey 44 49 ··· 85 90 fun TimelinePostActionsView( 86 91 modifier: Modifier = Modifier, 87 92 timelineViewModel: TimelineViewModel?, 88 - onReplyTap: (SkeetData) -> Unit = {}, 93 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 89 94 skeet: SkeetData, 90 95 inThread: Boolean = false, 91 96 ) { ··· 132 137 133 138 IconButton( 134 139 onClick = { 135 - onReplyTap(skeet) 140 + onReplyTap(skeet, false) 136 141 } 137 142 ) { 138 143 IconWithNumber( ··· 180 185 } 181 186 182 187 var isReposted by rememberSaveable { mutableStateOf(skeet.didRepost) } 183 - IconButton( 188 + LongPressIconButton( 184 189 onClick = { 185 190 when (isReposted) { 186 191 false -> timelineViewModel?.repost(skeet.uri, skeet.cid) { ··· 193 198 reposts.longValue-- 194 199 } 195 200 } 196 - 201 + }, 202 + onLongClick = { 203 + onReplyTap(skeet, true) 197 204 } 198 205 ) { 199 206 IconWithNumber( ··· 209 216 HorizontalDivider( 210 217 color = MaterialTheme.colorScheme.outlineVariant 211 218 ) 219 + } 220 + } 221 + 222 + @Composable 223 + fun LongPressIconButton( 224 + modifier: Modifier = Modifier, 225 + stepDelay: Long = 100L, // Minimum value is 1L milliseconds. 226 + onClick: () -> Unit = {}, 227 + onLongClick: () -> Unit = {}, 228 + content: @Composable () -> Unit, 229 + ) { 230 + val interactionSource = remember { MutableInteractionSource() } 231 + val isPressed by interactionSource.collectIsPressedAsState() 232 + val pressedListener by rememberUpdatedState(onLongClick) 233 + 234 + LaunchedEffect(isPressed) { 235 + if (isPressed) { 236 + delay(stepDelay.coerceIn(1L, Long.MAX_VALUE)) 237 + pressedListener() 238 + } 239 + } 240 + 241 + IconButton( 242 + modifier = modifier, 243 + onClick = if (isPressed) { 244 + {} 245 + } else { 246 + onClick 247 + }, 248 + interactionSource = interactionSource 249 + ) { 250 + content() 212 251 } 213 252 }
+7 -4
app/src/main/java/industries/geesawra/monarch/TimelineView.kt
··· 93 93 ) 94 94 95 95 val inReplyTo = remember { mutableStateOf<SkeetData?>(null) } 96 + val isQuotePost = remember { mutableStateOf(false) } 96 97 97 98 BottomSheetScaffold( 98 99 modifier = Modifier ··· 109 110 timelineViewModel = timelineViewModel, 110 111 scaffoldState = scaffoldState, 111 112 scrollState = scrollState, 112 - inReplyTo = inReplyTo 113 + inReplyTo = inReplyTo, 114 + isQuotePost = isQuotePost 113 115 ) 114 116 }, 115 117 content = { paddingValues -> ··· 122 124 scaffoldState.bottomSheetState.expand() 123 125 } 124 126 }, 125 - onReplyTap = { 126 - inReplyTo.value = it 127 + onReplyTap = { skeetData, quotePost -> 128 + inReplyTo.value = skeetData 129 + isQuotePost.value = quotePost 127 130 coroutineScope.launch { 128 131 scaffoldState.bottomSheetState.expand() 129 132 } ··· 140 143 modifier: Modifier = Modifier, 141 144 coroutineScope: CoroutineScope, 142 145 timelineViewModel: TimelineViewModel, 143 - onReplyTap: (SkeetData) -> Unit = {}, 146 + onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 144 147 fobOnClick: () -> Unit, 145 148 loginError: () -> Unit, 146 149 ) {
+29 -20
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 12 12 import app.bsky.actor.PreferencesUnion 13 13 import app.bsky.embed.Images 14 14 import app.bsky.embed.ImagesImage 15 + import app.bsky.embed.Record 15 16 import app.bsky.embed.Video 16 17 import app.bsky.feed.GeneratorView 17 18 import app.bsky.feed.GetFeedGeneratorsQueryParams ··· 432 433 content: String, 433 434 images: List<Uri>? = null, 434 435 video: Uri? = null, 435 - replyRef: PostReplyRef? = null 436 + replyRef: PostReplyRef? = null, 437 + quotePostRef: StrongRef? = null 436 438 ): Result<Unit> { 437 439 // TODO: videos need to be uploaded through a different API. 438 440 return runCatching { ··· 442 444 443 445 var postEmbed: PostEmbedUnion? = null 444 446 445 - if (images != null) { 446 - val blobs = uploadImages(images).getOrThrow() 447 - postEmbed = PostEmbedUnion.Images( 448 - value = Images( 449 - images = blobs.map { 450 - ImagesImage( 451 - image = it, 452 - alt = "", 453 - ) 454 - } 455 - ) 447 + if (quotePostRef != null) { // TODO: handle image/video plus quote 448 + postEmbed = PostEmbedUnion.Record( 449 + value = Record(quotePostRef) 456 450 ) 457 - } 451 + } else { 458 452 459 - if (video != null) { 460 - val blob = uploadVideo(video).getOrThrow() 461 - postEmbed = PostEmbedUnion.Video( 462 - value = Video( 463 - video = blob, 464 - alt = "", 453 + if (images != null) { 454 + val blobs = uploadImages(images).getOrThrow() 455 + postEmbed = PostEmbedUnion.Images( 456 + value = Images( 457 + images = blobs.map { 458 + ImagesImage( 459 + image = it, 460 + alt = "", 461 + ) 462 + } 463 + ) 465 464 ) 466 - ) 465 + } 466 + 467 + if (video != null) { 468 + val blob = uploadVideo(video).getOrThrow() 469 + postEmbed = PostEmbedUnion.Video( 470 + value = Video( 471 + video = blob, 472 + alt = "", 473 + ) 474 + ) 475 + } 467 476 } 468 477 469 478 val r = BlueskyJson.encodeAsJsonContent(
+4 -1
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 8 8 import androidx.lifecycle.viewModelScope 9 9 import app.bsky.feed.GeneratorView 10 10 import app.bsky.feed.PostReplyRef 11 + import com.atproto.repo.StrongRef 11 12 import dagger.assisted.Assisted 12 13 import dagger.assisted.AssistedFactory 13 14 import dagger.assisted.AssistedInject ··· 109 110 content: String, 110 111 images: List<Uri>? = null, 111 112 video: Uri? = null, 112 - replyRef: PostReplyRef? = null 113 + replyRef: PostReplyRef? = null, 114 + quotePostRef: StrongRef? = null, 113 115 ): Result<Unit> { 114 116 return bskyConn.post( 115 117 content, 116 118 images, 117 119 video, 118 120 replyRef, 121 + quotePostRef 119 122 ) // TODO: maybe refactor this to use uistate.Error? 120 123 } 121 124