[1.558.*] Pre-release merge (#1189)

This commit is contained in:
tramline-github[bot]
2026-06-02 14:30:11 +00:00
committed by GitHub
50 changed files with 233 additions and 208 deletions
+8 -1
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.66.0] - 2026-06-02
### Added
- You can now upvote and reply to comments from the app, if you're logged in
@@ -15,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The comments UI now tries to mimic Boost for Reddit
### Fixed
- Certificate pinning was removed to avoid errors when certificates were rotated
## [1.65.0] - 2026-05-29
### Fixed
@@ -629,7 +635,8 @@ Thanks to Charles Lombardo for contributing the action button UI changes.
- Initial Play Store release
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.65.0...HEAD
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.66.0...HEAD
[1.66.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.65.0...v1.66.0
[1.65.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.64.0...v1.65.0
[1.64.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.63.0...v1.64.0
[1.63.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.62.0...v1.63.0
+1
View File
@@ -114,6 +114,7 @@ dependencies {
implementation(libs.kotlin.parcelize.runtime)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlin.stdlib)
@@ -39,9 +39,8 @@ import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
@@ -49,6 +48,9 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@Inject
@ViewModelKey
@@ -173,7 +175,7 @@ class ClawViewModel(
* solution.
*/
private fun String.toLocalDateTime(): LocalDateTime {
if (isEmpty()) return LocalDateTime.now(ZoneId.systemDefault())
return LocalDateTime.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(this))
if (isEmpty()) return Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
return Instant.parse(this).toLocalDateTime(TimeZone.currentSystemDefault())
}
}
@@ -12,8 +12,7 @@ import dev.msfjarvis.claw.database.local.SavedPost
import dev.zacsweers.metro.Inject
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -49,11 +48,8 @@ class DataTransferRepository(
}
suspend fun exportPostsAsHTML(output: OutputStream) {
fun computeTimestamp(post: SavedPost): Long {
val temporal = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(post.createdAt)
val instant = Instant.from(temporal)
return instant.toEpochMilli()
}
fun computeTimestamp(post: SavedPost): Long =
Instant.parse(post.createdAt).toEpochMilliseconds()
val posts = savedPostsRepository.savedPosts.first()
val header =
+2 -2
View File
@@ -1,5 +1,5 @@
#
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
#
versioning-plugin.versionCode=16600
versioning-plugin.versionName=1.66.0-SNAPSHOT
versioning-plugin.versionCode=16700
versioning-plugin.versionName=1.67.0-SNAPSHOT
+1
View File
@@ -28,5 +28,6 @@ dependencies {
testImplementation(libs.eithernet.test.fixtures)
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.datetime)
addTestDependencies(project)
}
@@ -14,8 +14,7 @@ import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.assertIs
import java.time.Instant
import java.time.format.DateTimeFormatter
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -54,7 +53,7 @@ class ApiTest {
assertThat(firstPost.tags).containsExactly("linux")
assertThat(firstPost.userIsAuthor).isTrue()
assertThat(firstPost.createdAt).isEqualTo("2026-05-29T09:08:12Z")
DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(firstPost.createdAt)
Instant.parse(firstPost.createdAt)
val secondPost = posts.value.posts[1]
assertThat(secondPost.shortId).isEqualTo("lc26ar")
@@ -143,7 +142,7 @@ class ApiTest {
val editedComment = postDetails.value.comments.first { it.shortId == "pcvbcd" }
assertThat(editedComment.edited).isTrue()
assertThat(Instant.from(editedComment.timestamp).epochSecond).isEqualTo(1658588955)
assertThat(editedComment.timestamp.epochSeconds).isEqualTo(1658588955)
}
@Test
+1
View File
@@ -64,6 +64,7 @@ dependencies {
implementation(libs.kotlinResult)
implementation(libs.kotlinResult.coroutines)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.datetime)
implementation(libs.metrox.viewmodel.compose)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.extensions.coroutines)
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Reply
@@ -56,14 +57,11 @@ import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.PostTitle
import dev.msfjarvis.claw.common.posts.Submitter
import dev.msfjarvis.claw.common.posts.TagRow
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.Comment
import dev.msfjarvis.claw.model.LinkMetadata
import dev.msfjarvis.claw.model.UIPost
import java.time.Instant
import java.time.temporal.TemporalAccessor
import kotlin.time.Instant
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -241,6 +239,18 @@ internal fun CommentEntry(
modifier = Modifier.clickable { openUserProfile(comment.user) },
)
Spacer(Modifier.weight(1f))
if (!isExpanded) {
Text(
text = " +${commentNode.descendantCount()} ",
style = MaterialTheme.typography.labelLargeEmphasized,
color = MaterialTheme.colorScheme.onTertiary,
modifier =
Modifier.background(
color = MaterialTheme.colorScheme.tertiary,
shape = RoundedCornerShape(CornerSize(4.dp)),
),
)
}
Text(
text = score.toString(),
style = MaterialTheme.typography.labelLarge,
@@ -317,24 +327,6 @@ private fun CommentActionTray(
}
}
@Composable
internal fun PreviewCommentEntry(commentNode: CommentNode) {
LobstersTheme(darkTheme = true) {
Box(Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 8.dp)) {
CommentEntry(
isExpanded = true,
commentNode = commentNode,
openUserProfile = {},
onToggleExpandedState = { _, _ -> },
isLoggedIn = true,
upvoteComment = {},
unvoteComment = {},
onReply = { _, _ -> },
)
}
}
}
private fun displayScore(score: Int, initiallyUpvoted: Boolean, isUpvoted: Boolean): Int {
return when {
initiallyUpvoted == isUpvoted -> score
@@ -343,30 +335,11 @@ private fun displayScore(score: Int, initiallyUpvoted: Boolean, isUpvoted: Boole
}
}
internal fun previewCommentNode(isUpvoted: Boolean = false) =
CommentNode(
comment =
Comment(
shortId = "preview-comment",
comment =
"<p>This is a preview comment with enough content to evaluate spacing, metadata, and future vote affordances.</p>",
score = 42,
timestamp = Instant.now(),
edited = false,
parentComment = null,
user = "Alice",
isUpvoted = isUpvoted,
),
isPostAuthor = false,
isUnread = true,
indentLevel = 0,
)
private fun buildCommentAgeString(timestamp: TemporalAccessor, edited: Boolean): String {
private fun buildCommentAgeString(timestamp: Instant, edited: Boolean): String {
val now = System.currentTimeMillis()
val relativeTime =
DateUtils.getRelativeTimeSpanString(
Instant.from(timestamp).toEpochMilli(),
timestamp.toEpochMilliseconds(),
now,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
@@ -37,6 +37,10 @@ internal data class CommentNode(
}
}
fun descendantCount(): Int {
return children.size + children.sumOf { it.descendantCount() }
}
/**
* [CommentNode.equals], [CommentNode.toString] and [CommentNode.hashCode] are hand-rolled to drop
* the [CommentNode.parent] field from the comparison since it's possible for there to be cycles
@@ -52,10 +52,12 @@ import dev.msfjarvis.claw.common.R
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.model.Tag
import dev.zacsweers.metrox.viewmodel.metroViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
import kotlinx.collections.immutable.ImmutableList
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@OptIn(ExperimentalFlexBoxApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -72,9 +74,9 @@ fun TagList(
selectedTagForDatePicker?.let { tagName ->
if (showDatePicker) {
val tomorrow = Instant.now().plusMillis(24 * 60 * 60 * 1000)
val tomorrow = Clock.System.now().plus(1.days)
val datePickerState =
rememberDatePickerState(initialSelectedDateMillis = tomorrow.toEpochMilli())
rememberDatePickerState(initialSelectedDateMillis = tomorrow.toEpochMilliseconds())
DatePickerDialog(
onDismissRequest = {
@@ -207,7 +209,7 @@ fun TagList(
}
private fun formatDate(millis: Long): String {
val instant = Instant.ofEpochMilli(millis)
val formatter = DateTimeFormatter.ofPattern("MMM dd").withZone(ZoneId.systemDefault())
return formatter.format(instant)
val date = Instant.fromEpochMilliseconds(millis).toLocalDateTime(TimeZone.currentSystemDefault())
val month = date.month.name.lowercase().take(3).replaceFirstChar(Char::titlecase)
return "$month ${date.day.toString().padStart(2, '0')}"
}
@@ -6,23 +6,102 @@
*/
package dev.msfjarvis.claw.common.comments
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.android.tools.screenshot.PreviewTest
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
import dev.msfjarvis.claw.model.Comment
import kotlin.time.Clock
@PreviewTest
@DevicePreviews
@ThemePreviews
@Composable
private fun CommentEntryPreview(
@PreviewParameter(BooleanPreviewParameterProvider::class) isUpvoted: Boolean
@PreviewParameter(CommentEntryPreviewParameterProvider::class)
params: CommentEntryPreviewParameters
) {
PreviewCommentEntry(previewCommentNode(isUpvoted))
LobstersTheme(darkTheme = true) {
Box(Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 8.dp)) {
CommentEntry(
isExpanded = params.isExpanded,
commentNode =
previewCommentNode(isUpvoted = params.isUpvoted, hasChildren = params.hasChildren),
openUserProfile = {},
onToggleExpandedState = { _, _ -> },
isLoggedIn = true,
upvoteComment = {},
unvoteComment = {},
onReply = { _, _ -> },
)
}
}
}
private class BooleanPreviewParameterProvider(
override val values: Sequence<Boolean> = sequenceOf(true, false)
) : PreviewParameterProvider<Boolean>
private data class CommentEntryPreviewParameters(
val isUpvoted: Boolean,
val hasChildren: Boolean,
val isExpanded: Boolean,
)
private class CommentEntryPreviewParameterProvider :
PreviewParameterProvider<CommentEntryPreviewParameters> {
override val values: Sequence<CommentEntryPreviewParameters> =
sequenceOf(
CommentEntryPreviewParameters(isUpvoted = true, hasChildren = false, isExpanded = true),
CommentEntryPreviewParameters(isUpvoted = false, hasChildren = false, isExpanded = true),
CommentEntryPreviewParameters(isUpvoted = true, hasChildren = true, isExpanded = true),
CommentEntryPreviewParameters(isUpvoted = false, hasChildren = true, isExpanded = false),
)
}
private fun previewCommentNode(isUpvoted: Boolean = false, hasChildren: Boolean = false) =
CommentNode(
comment =
Comment(
shortId = "preview-comment",
comment =
"<p>This is a preview comment with enough content to evaluate spacing, metadata, and future vote affordances.</p>",
score = 42,
timestamp = Clock.System.now(),
edited = false,
parentComment = null,
user = "Alice",
isUpvoted = isUpvoted,
),
isPostAuthor = false,
isUnread = true,
indentLevel = 0,
)
.also { parent ->
if (hasChildren) {
parent.addChild(
CommentNode(
comment =
Comment(
shortId = "preview-child-comment",
comment =
"<p>This child comment helps preview expanded and collapsed thread states.</p>",
score = 7,
timestamp = Clock.System.now(),
edited = false,
parentComment = parent.comment.shortId,
user = "Bob",
isUpvoted = false,
),
isPostAuthor = false,
isUnread = false,
indentLevel = 1,
)
)
}
}
@@ -9,7 +9,7 @@ package dev.msfjarvis.claw.common.comments
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment
import java.time.Instant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class CommentsHandlerTest {
@@ -157,7 +157,7 @@ class CommentsHandlerTest {
comment = "Comment $shortId",
url = "https://lobste.rs/s/$shortId",
score = 1,
timestamp = Instant.EPOCH,
timestamp = Instant.fromEpochSeconds(0),
edited = false,
parentComment = parentComment,
user = "user-$shortId",
+1
View File
@@ -24,6 +24,7 @@ dependencies {
api(libs.retrofit)
implementation(platform(libs.okhttp.bom))
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.core)
implementation(libs.retrofit.kotlinxSerializationConverter)
}
@@ -18,7 +18,6 @@ import java.net.Socket
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import okhttp3.Cache
import okhttp3.CertificatePinner
import okhttp3.CookieJar
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@@ -33,15 +32,6 @@ object OkHttpModule {
private const val READ_TIMEOUT_SECONDS = 30L
private const val WRITE_TIMEOUT_SECONDS = 30L
@Provides
fun provideCertificatePinner(): CertificatePinner {
return CertificatePinner.Builder()
.add("lobste.rs", "sha256/Bla1TIdpGeHXQS0/CIrA5hhFhOTZd94IIJRS3G3AcIo=")
.add("lobste.rs", "sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=")
.add("lobste.rs", "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=")
.build()
}
@Provides
@SingleIn(AppScope::class)
fun provideCache(context: Context): Cache {
@@ -64,7 +54,6 @@ object OkHttpModule {
cache: Cache,
socketFactory: SocketFactory,
interceptors: Set<Interceptor>,
certificatePinner: CertificatePinner,
cookieJar: CookieJar,
): OkHttpClient {
return OkHttpClient.Builder()
@@ -82,7 +71,6 @@ object OkHttpModule {
// RetryAfterInterceptor needs to call proceed() twice when retrying after a delay.
interceptors.forEach(::addInterceptor)
socketFactory(socketFactory)
certificatePinner(certificatePinner)
}
.build()
}
@@ -9,12 +9,8 @@ package dev.msfjarvis.claw.core.network
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlinx.datetime.format.DateTimeComponents
import okhttp3.Interceptor
import okhttp3.Response
@@ -68,32 +64,23 @@ class RetryAfterInterceptor : Interceptor {
* Returns the delay in seconds, or 0 if parsing fails.
*/
private fun parseRetryAfter(retryAfter: String): Long {
// Try parsing as seconds first
retryAfter.toLongOrNull()?.let {
return it.coerceAtMost(MAX_RETRY_DELAY_SECONDS)
}
// Try parsing as HTTP-date
try {
val retryDate = ZonedDateTime.parse(retryAfter, HTTP_DATE_FORMATTER)
val now = Instant.now()
val retryInstant = retryDate.toInstant()
val delaySeconds = retryInstant.epochSecond - now.epochSecond
// Only return positive delays, capped at max
return if (delaySeconds > 0) delaySeconds.coerceAtMost(MAX_RETRY_DELAY_SECONDS) else 0
} catch (e: DateTimeParseException) {
// If we can't parse it, don't retry
return 0
}
val retryInstant = parseHttpDate(retryAfter) ?: return 0
val delaySeconds = (retryInstant.toEpochMilliseconds() - System.currentTimeMillis()) / 1000
return if (delaySeconds > 0) delaySeconds.coerceAtMost(MAX_RETRY_DELAY_SECONDS) else 0
}
private fun parseHttpDate(value: String) =
try {
DateTimeComponents.Formats.RFC_1123.parse(value).toInstantUsingOffset()
} catch (_: IllegalArgumentException) {
null
}
private companion object {
// Cap retry delay at 5 minutes to avoid indefinite waits
private const val MAX_RETRY_DELAY_SECONDS = 300L
// HTTP-date format as specified in RFC 7231
private val HTTP_DATE_FORMATTER =
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
}
}
+1
View File
@@ -20,6 +20,7 @@ dependencies {
api(projects.database.core)
implementation(libs.androidx.sqlite)
implementation(libs.kotlinx.datetime)
implementation(libs.sqldelight.androidDriver)
implementation(libs.sqldelight.primitiveAdapters)
implementation(libs.sqlite.android)
@@ -7,9 +7,8 @@
package dev.msfjarvis.claw.database.local
import com.google.common.truth.Truth.assertThat
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -114,13 +113,8 @@ class SavedPostQueriesTest {
@Test
fun `select posts from last N days`() {
val now = Instant.now()
val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
fun dateOffset(daysToSubtract: Long): String {
return formatter.format(
now.minus(daysToSubtract, ChronoUnit.DAYS).atOffset(java.time.ZoneOffset.UTC)
)
}
val now = Clock.System.now()
fun dateOffset(daysToSubtract: Long): String = (now - daysToSubtract.days).toString()
val recentPost = createPostWithDate(id = "recent_1", createdAt = dateOffset(5))
val oldPost = createPostWithDate(id = "old_1", createdAt = dateOffset(40))
@@ -176,13 +170,8 @@ class SavedPostQueriesTest {
@Test
fun `selectRecentPosts returns posts in descending date order`() {
val now = Instant.now()
val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
fun dateOffset(daysToSubtract: Long): String {
return formatter.format(
now.minus(daysToSubtract, ChronoUnit.DAYS).atOffset(java.time.ZoneOffset.UTC)
)
}
val now = Clock.System.now()
fun dateOffset(daysToSubtract: Long): String = (now - daysToSubtract.days).toString()
val oldest = createPostWithDate(id = "oldest", createdAt = dateOffset(30))
val middle = createPostWithDate(id = "middle", createdAt = dateOffset(15))
@@ -201,13 +190,8 @@ class SavedPostQueriesTest {
@Test
fun `selectAllPostsSortedByDate returns posts in descending date order`() {
val now = Instant.now()
val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
fun dateOffset(daysToSubtract: Long): String {
return formatter.format(
now.minus(daysToSubtract, ChronoUnit.DAYS).atOffset(java.time.ZoneOffset.UTC)
)
}
val now = Clock.System.now()
fun dateOffset(daysToSubtract: Long): String = (now - daysToSubtract.days).toString()
val post1 = createPostWithDate(id = "post_1", createdAt = dateOffset(20))
val post2 = createPostWithDate(id = "post_2", createdAt = dateOffset(5))
@@ -0,0 +1,12 @@
### Added
- You can now upvote and reply to comments from the app, if you're logged in
### Changed
- The comments UI now tries to mimic Boost for Reddit
### Fixed
- Certificate pinning was removed to avoid errors when certificates were rotated
+2 -1
View File
@@ -133,6 +133,7 @@ kotlinResult-coroutines = { module = "com.michael-bull.kotlin-result:kotlin-resu
kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0"
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat"
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
metrox-android = { module = "dev.zacsweers.metro:metrox-android", version.ref = "metro" }
@@ -173,7 +174,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref =
aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" }
android-lint = { id = "com.android.lint", version.ref = "agp" }
android-test = { id = "com.android.test", version.ref = "agp" }
dependencyAnalysis = "com.autonomousapps.dependency-analysis:3.14.0"
dependencyAnalysis = "com.autonomousapps.dependency-analysis:3.14.1"
baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
kotlin-composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+1
View File
@@ -13,6 +13,7 @@ plugins {
}
dependencies {
api(libs.kotlinx.datetime)
api(libs.kotlinx.serialization.core)
api(libs.kspoon)
api(projects.database.core)
@@ -11,10 +11,7 @@ package dev.msfjarvis.claw.model
import dev.burnoo.kspoon.SelectorHtmlTextMode
import dev.burnoo.kspoon.annotation.Selector
import dev.drewhamilton.poko.Poko
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
import kotlin.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -36,7 +33,7 @@ class Comment(
val score: Int = 1,
@Serializable(with = CommentInstantSerializer::class)
@Selector("div.byline a[href^=/c/] time", attr = "data-at-unix", defValue = "")
val timestamp: TemporalAccessor,
val timestamp: Instant,
@Serializable(with = CommentEditedSerializer::class)
@Selector("div.byline span", defValue = "")
val edited: Boolean = false,
@@ -51,27 +48,13 @@ class Comment(
val isUpvoted: Boolean = false,
)
internal object CommentInstantSerializer : KSerializer<TemporalAccessor> {
internal object CommentInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("CommentInstant", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): TemporalAccessor {
val value = decoder.decodeString()
val isoValue =
if (value.isBlank()) {
Instant.EPOCH.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} else if (value.all(Char::isDigit)) {
Instant.ofEpochSecond(value.toLong())
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} else {
value
}
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(isoValue)
}
override fun deserialize(decoder: Decoder): Instant = decoder.decodeString().parseInstantOrEpoch()
override fun serialize(encoder: Encoder, value: TemporalAccessor) =
encoder.encodeString(value.toString())
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
}
internal object CommentScoreSerializer : KSerializer<Int> {
@@ -8,10 +8,6 @@ package dev.msfjarvis.claw.model
import com.fleeksoft.ksoup.nodes.Element
import dev.burnoo.kspoon.decoder.KspoonDecoder
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
@@ -55,7 +51,7 @@ internal object CommentsSerializer : KSerializer<List<Comment>> {
private fun Element.toComment(parentComment: String?): Comment {
val byline = selectFirst("div.byline")
val timestamp = byline?.selectFirst("a[href^=/c/] time")?.attr("data-at-unix").orEmpty()
val parsedTimestamp = timestamp.toTemporalAccessor()
val parsedTimestamp = timestamp.parseInstantOrEpoch()
val isEdited = byline?.text()?.contains("edited") == true
return Comment(
shortId = attr("data-shortid"),
@@ -83,20 +79,6 @@ internal object CommentsSerializer : KSerializer<List<Comment>> {
)
}
private fun String.toTemporalAccessor(): TemporalAccessor {
val isoValue =
if (isBlank()) {
Instant.EPOCH.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} else if (all(Char::isDigit)) {
Instant.ofEpochSecond(toLong())
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} else {
this
}
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(isoValue)
}
override fun serialize(encoder: Encoder, value: List<Comment>) =
throw UnsupportedOperationException()
}
@@ -0,0 +1,44 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.model
import kotlin.time.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format.FormatStringsInDatetimeFormats
import kotlinx.datetime.format.byUnicodePattern
import kotlinx.datetime.toInstant
internal fun String.parseInstantOrEpoch(): Instant =
parseInstantOrNull() ?: Instant.fromEpochSeconds(0)
internal fun String.parseInstantOrNull(): Instant? {
return when {
isBlank() -> Instant.fromEpochSeconds(0)
all(Char::isDigit) -> toLongOrNull()?.let(Instant::fromEpochSeconds)
else -> parseIsoInstant() ?: parseLegacyLobstersDateTime()?.toInstant(TimeZone.UTC)
}
}
private fun String.parseIsoInstant(): Instant? =
try {
Instant.parse(this)
} catch (_: IllegalArgumentException) {
null
}
private fun String.parseLegacyLobstersDateTime(): LocalDateTime? =
try {
LEGACY_LOBSTERS_DATE_TIME_FORMAT.parse(this)
} catch (_: IllegalArgumentException) {
null
}
@OptIn(FormatStringsInDatetimeFormats::class)
private val LEGACY_LOBSTERS_DATE_TIME_FORMAT = LocalDateTime.Format {
byUnicodePattern("yyyy-MM-dd HH:mm:ss")
}
@@ -12,10 +12,6 @@ import dev.burnoo.kspoon.annotation.Selector
import dev.drewhamilton.poko.Poko
import io.mcarle.konvert.api.KonvertTo
import io.mcarle.konvert.api.Mapping
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -70,23 +66,7 @@ internal object CreatedAtSerializer : KSerializer<String> {
override fun deserialize(decoder: Decoder): String {
val value = decoder.decodeString()
return when {
value.isBlank() -> ""
value.all(Char::isDigit) ->
value.toLongOrNull()?.let {
Instant.ofEpochSecond(it)
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
} ?: ""
runCatching { DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value) }.isSuccess -> value
else ->
runCatching {
LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
.getOrDefault("")
}
return if (value.isBlank()) "" else value.parseInstantOrNull()?.toString().orEmpty()
}
override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value)
@@ -6,8 +6,7 @@
*/
package dev.msfjarvis.claw.serialization
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
import kotlin.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -15,14 +14,11 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
class JavaInstantSerializer : KSerializer<TemporalAccessor> {
class JavaInstantSerializer : KSerializer<Instant> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): TemporalAccessor {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(decoder.decodeString())
}
override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: TemporalAccessor) =
encoder.encodeString(value.toString())
override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString())
}