[1.558.*] Pre-release merge (#1189)
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||