A cheap attempt at a native Bluesky client for Android

SkeetView: Show relative creation time

Show a human-readable, relative timestamp for when a skeet was created (e.g., "5 minutes ago"). This required adding the `Human-Readable` and `kotlinx-datetime` libraries.

The internal `SkeetData` model was updated to use `kotlin.time.Instant` instead of the Ozone `Timestamp`.

+41 -15
+2
app/build.gradle.kts
··· 77 77 implementation("androidx.media3:media3-transformer:1.8.0") 78 78 implementation("androidx.media3:media3-effect:1.8.0") 79 79 implementation("androidx.media3:media3-common:1.8.0") 80 + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat") 81 + implementation("nl.jacobras:Human-Readable:1.12.0") 80 82 implementation(libs.androidx.compose.animation.core.lint) 81 83 implementation(libs.androidx.material3) 82 84 ksp("com.google.dagger:hilt-compiler:2.57.2")
+15 -2
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 1 + @file:OptIn(ExperimentalTime::class) 2 + 1 3 package industries.geesawra.monarch 2 4 3 5 import android.content.Context ··· 29 31 import androidx.compose.ui.layout.ContentScale 30 32 import androidx.compose.ui.platform.LocalContext 31 33 import androidx.compose.ui.text.font.FontWeight 34 + import androidx.compose.ui.text.style.TextAlign 32 35 import androidx.compose.ui.unit.dp 33 36 import androidx.core.net.toUri 34 37 import androidx.media3.common.MimeTypes ··· 50 53 import io.sanghun.compose.video.VideoPlayer 51 54 import io.sanghun.compose.video.controller.VideoPlayerControllerConfig 52 55 import io.sanghun.compose.video.uri.VideoPlayerMediaItem 56 + import nl.jacobras.humanreadable.HumanReadable 57 + import kotlin.time.ExperimentalTime 53 58 54 59 @Composable 55 60 fun SkeetView( ··· 411 416 text = "@" + skeet.authorHandle, 412 417 color = MaterialTheme.colorScheme.onSurfaceVariant, 413 418 style = MaterialTheme.typography.bodySmall, 414 - modifier = Modifier 415 - .padding(top = 4.dp), 416 419 ) 417 420 418 421 skeet.authorLabels.forEach { ··· 454 457 455 458 else -> {} 456 459 } 460 + } 461 + 462 + skeet.createdAt?.let { 463 + Text( 464 + text = HumanReadable.timeAgo(it), 465 + color = MaterialTheme.colorScheme.onSurfaceVariant, 466 + style = MaterialTheme.typography.bodySmall, 467 + textAlign = TextAlign.End, 468 + modifier = Modifier.fillMaxWidth() 469 + ) 457 470 } 458 471 } 459 472 }
+9 -5
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 1 + @file:OptIn(ExperimentalTime::class) 2 + 1 3 package industries.geesawra.monarch.datalayer 2 4 3 5 import android.content.Context ··· 62 64 import kotlinx.coroutines.flow.first 63 65 import kotlinx.coroutines.flow.map 64 66 import kotlinx.coroutines.sync.Mutex 65 - import kotlinx.datetime.Clock 66 - import kotlinx.datetime.Instant 67 + import kotlinx.datetime.toDeprecatedInstant 67 68 import kotlinx.serialization.Serializable 68 69 import kotlinx.serialization.json.Json 69 70 import sh.christian.ozone.BlueskyJson ··· 79 80 import sh.christian.ozone.api.model.Blob 80 81 import sh.christian.ozone.api.model.JsonContent.Companion.encodeAsJsonContent 81 82 import sh.christian.ozone.api.response.AtpResponse 83 + import kotlin.time.Clock 82 84 import kotlin.time.Duration 85 + import kotlin.time.ExperimentalTime 86 + import kotlin.time.Instant 83 87 84 88 enum class AuthData { 85 89 PDSHost, ··· 480 484 val r = BlueskyJson.encodeAsJsonContent( 481 485 Post( 482 486 text = content, 483 - createdAt = Clock.System.now(), 487 + createdAt = Clock.System.now().toDeprecatedInstant(), 484 488 embed = postEmbed, 485 489 reply = replyRef 486 490 ) ··· 716 720 val like = BlueskyJson.encodeAsJsonContent( 717 721 Like( 718 722 subject = StrongRef(uri, cid), 719 - createdAt = Clock.System.now(), 723 + createdAt = Clock.System.now().toDeprecatedInstant(), 720 724 ) 721 725 ) 722 726 ··· 747 751 val like = BlueskyJson.encodeAsJsonContent( 748 752 Repost( 749 753 subject = StrongRef(uri, cid), 750 - createdAt = Clock.System.now(), 754 + createdAt = Clock.System.now().toDeprecatedInstant(), 751 755 ) 752 756 ) 753 757
+10 -6
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 1 + @file:OptIn(ExperimentalTime::class) 2 + 1 3 package industries.geesawra.monarch.datalayer 2 4 3 5 import app.bsky.embed.RecordViewRecord ··· 13 15 import app.bsky.feed.ReplyRefRootUnion 14 16 import com.atproto.label.Label 15 17 import com.atproto.repo.StrongRef 18 + import kotlinx.datetime.toStdlibInstant 16 19 import sh.christian.ozone.api.AtUri 17 20 import sh.christian.ozone.api.Cid 18 21 import sh.christian.ozone.api.Handle 19 - import sh.christian.ozone.api.model.Timestamp 22 + import kotlin.time.ExperimentalTime 23 + import kotlin.time.Instant 20 24 21 25 data class SkeetData( 22 26 val likes: Long? = null, ··· 34 38 var embed: PostViewEmbedUnion? = null, 35 39 val reason: FeedViewPostReasonUnion? = null, 36 40 val reply: ReplyRef? = null, 37 - val createdAt: Timestamp? = null, 41 + val createdAt: Instant? = null, 38 42 39 43 val blocked: Boolean = false, 40 44 val notFound: Boolean = false, ··· 59 63 embed = post.post.embed, 60 64 reason = post.reason, 61 65 reply = post.reply, 62 - createdAt = content.createdAt 66 + createdAt = content.createdAt.toStdlibInstant() 63 67 ) 64 68 } 65 69 ··· 80 84 authorLabels = post.author.labels, 81 85 content = content.text, 82 86 embed = post.embed, 83 - createdAt = content.createdAt 87 + createdAt = content.createdAt.toStdlibInstant() 84 88 ) 85 89 } 86 90 ··· 120 124 embed = embed, 121 125 reason = null, 122 126 reply = null, 123 - createdAt = content.createdAt 127 + createdAt = content.createdAt.toStdlibInstant() 124 128 ) 125 129 } 126 130 } 127 - 131 + 128 132 fun replyRef(): PostReplyRef { 129 133 val thisPostRef = StrongRef(this.uri, this.cid) 130 134
+5 -2
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 1 + @file:OptIn(ExperimentalTime::class) 2 + 1 3 package industries.geesawra.monarch.datalayer 2 4 3 5 import android.net.Uri ··· 16 18 import dagger.hilt.android.lifecycle.HiltViewModel 17 19 import kotlinx.coroutines.Job 18 20 import kotlinx.coroutines.launch 19 - import kotlinx.datetime.Clock 20 - import kotlinx.datetime.Instant 21 21 import sh.christian.ozone.api.AtUri 22 22 import sh.christian.ozone.api.Cid 23 23 import sh.christian.ozone.api.RKey 24 24 import kotlin.coroutines.cancellation.CancellationException 25 + import kotlin.time.Clock 26 + import kotlin.time.ExperimentalTime 27 + import kotlin.time.Instant 25 28 26 29 27 30 data class TimelineUiState(