Compare commits

...
206 Commits
Author SHA1 Message Date
GitHub Actions 55678d3914 feat(release): bump version 2026-06-02 10:20:42 +00:00
Harsh ShandilyaandGitHub 2661b82950 Update CHANGELOG.md 2026-06-02 15:45:56 +05:30
ClaudeGitHubanthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>msfjarvis
4bbf092c2d [WIP] Fix certificate pinning error on app launch (#1188)
Thanks for asking me to work on this. I will get started on it and keep
this PR's description up to date as I form a plan and make progress.

> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>1.65.0 Certificate Pinning error </issue_title>
> <issue_description>Opening the App just gives the error
> 
> > Certificate pinning failure
> 
> I imagine that this is due to the new certificate on lobster:
> 
> > Not Before Tue, 02 Jun 2026 03:17:13 GMT 
> 
> 
> backtrace:
> ```
> javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
>   Peer certificate chain:
>     sha256/nmaozNNuQ1MLbF/avDVhKPEu1JiZibqa/rcIhF6rJaU=: CN=lobste.rs
> sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=: CN=YE1,O=Let's
Encrypt,C=US
> sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root
YE,O=ISRG,C=US
> sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root
X2,O=Internet Security Research Group,C=US
>   Pinned certificates for lobste.rs:
>     sha256/Bla1TIdpGeHXQS0/CIrA5hhFhOTZd94IIJRS3G3AcIo=
>     sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=
>     sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=
> at
okhttp3.CertificatePinner.check$okhttp(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:427)
> at
okhttp3.internal.connection.ConnectPlan.connectTls(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:223)
> at
okhttp3.internal.connection.ConnectPlan.connectTlsEtc(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:153)
> at
okhttp3.internal.connection.FastFallbackExchangeFinder.find(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:139)
> at
okhttp3.internal.connection.ConnectInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:30)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:1872)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:666)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:220)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
io.sentry.okhttp.SentryOkHttpInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:461)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.logging.HttpLoggingInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:576)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.cache.CacheInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:773)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
dev.msfjarvis.claw.core.network.UserAgentInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:19)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
dev.msfjarvis.claw.core.network.RetryAfterInterceptor.intercept(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:3)
> at
okhttp3.internal.http.RealInterceptorChain.proceed(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:104)
> at
okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:118)
> at
okhttp3.internal.connection.RealCall$AsyncCall.run(r8-map-id-4720644bb8de639be45bbb06d58eed30b6402544fe3e08afbfc1a8ff6d7ebbc6:42)
> at
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1154)
> at
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:652)
> 	at java.lang.Thread.run(Thread.java:1563)
> Suppressed: javax.net.ssl.SSLPeerUnverifiedException: Certificate
pinning failure!
>   Peer certificate chain:
>     sha256/nmaozNNuQ1MLbF/avDVhKPEu1JiZibqa/rcIhF6rJaU=: CN=lobste.rs
> sha256/brzvtCELCIZUo4sD/qPX0ccRtPsd3DY6RfmxpOU9oB4=: CN=YE1,O=Let's
Encrypt,C=US
> sha256/sCkq5UWXjg+7mKu9lMhhYF5bGLsy7VI/UNW3tccdR7w=: CN=Root
YE,O=ISRG,C=US
> sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=: CN=ISRG Root
X2,O=Internet Security Research Group,C=US
>   Pinned certificates for lobste.rs:
>     sha256/Bla1TIdpGeHXQS0/CIrA5hhFhOTZd94IIJRS3G3AcIo=
>     sha256/jQJTbIh0grw0/1TkHSumWb+Fs0Ggogr621gT3PvPKG0=
>     sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=
> 		... 27 more
> ```
> 
> 
> Version: 1.65.0
> </issue_description>
> 
> <agent_instructions>Remove the certificate pinning
logic</agent_instructions>
> 
> ## Comments on the Issue (you are @claude[agent] in this section)
> 
> <comments>
> </comments>

---------

Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
2026-06-02 09:27:35 +00:00
renovate[bot]andGitHub 7cbf87080d fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.8.0-0.6.x-compat 2026-06-02 04:01:12 +00:00
Harsh Shandilya 784bda4105 refactor: drop all uses of java.time 2026-06-02 09:26:43 +05:30
renovate[bot]andGitHub 8adcef03d6 chore(deps): update plugin dependencyanalysis to v3.14.1 2026-06-01 22:09:41 +00:00
Harsh Shandilya 7efa5600d0 fix: give some padding to the collapsed comment count 2026-06-02 00:22:19 +05:30
Harsh Shandilya cb75ef0673 feat: add an indicator for collapsed comment count
Update the tests to account for the new UI affordance
2026-06-02 00:16:29 +05:30
Harsh Shandilya ae8db6d851 chore: update changelog 2026-06-01 00:11:33 +05:30
Harsh Shandilya 265a00e9b1 refactor: match the reply API request shape more closely
Without this we get a failed 404 reply even though the comment goes through just fine
2026-05-31 23:59:35 +05:30
Harsh Shandilya 94aa52b669 feat: add reply screen ui 2026-05-31 23:59:35 +05:30
Harsh Shandilya ca2dbd758c feat: add reply viewmodel 2026-05-31 23:59:35 +05:30
Harsh Shandilya fa22dbe4cf feat: add reply quote insertion helper 2026-05-31 18:57:53 +05:30
Harsh Shandilya 481efd24ab fix: improve comment click UX semantics 2026-05-31 18:43:37 +05:30
Harsh Shandilya 19346101c6 fix: use the correct thread guide color for each level 2026-05-31 18:43:25 +05:30
Harsh Shandilya b19a48fa09 fix: bump to Gradle 9.6.0-rc-1 2026-05-31 12:11:43 +05:30
renovate[bot]andGitHub 5457c204cf fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.5.1 2026-05-31 00:40:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
05e0664d14 fix(deps): update screenshot to v0.0.1-alpha15 (#1181)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.android.compose.screenshot](https://developer.android.com/studio/build)
([source](https://android.googlesource.com/platform/tools/base)) |
`0.0.1-alpha14` → `0.0.1-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.android.compose.screenshot:com.android.compose.screenshot.gradle.plugin/0.0.1-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.android.compose.screenshot:com.android.compose.screenshot.gradle.plugin/0.0.1-alpha14/0.0.1-alpha15?slim=true)
|
|
[com.android.tools.screenshot:screenshot-validation-api](http://tools.android.com/)
| `0.0.1-alpha14` → `0.0.1-alpha15` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.android.tools.screenshot:screenshot-validation-api/0.0.1-alpha15?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.android.tools.screenshot:screenshot-validation-api/0.0.1-alpha14/0.0.1-alpha15?slim=true)
|

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwMi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 03:40:01 +00:00
Harsh Shandilya e91338b31e fix: improve comment tree rendering 2026-05-29 23:51:12 +05:30
Harsh Shandilya f77221aeea chore: update changelog 2026-05-29 22:53:32 +05:30
Harsh Shandilya 8ee7d5dc28 Revert "feat: revert comments UI back to current stable for hotfix release"
This reverts commit 6d932c84d5.
2026-05-29 22:45:47 +05:30
GitHub Actions 76cb3376b7 feat(release): start next development iteration 2026-05-29 16:44:51 +00:00
GitHub Actions 9ba9791de8 feat(release): bump version 2026-05-29 16:39:35 +00:00
Harsh Shandilya 43408c2bec chore: update changelog 2026-05-29 21:59:24 +05:30
Harsh Shandilya 7ec580d9c6 fix: update selectors and test assertions 2026-05-29 21:52:59 +05:30
Harsh Shandilya 18b33e550a fix: refresh test fixtures 2026-05-29 21:52:59 +05:30
Harsh Shandilya 6d932c84d5 feat: revert comments UI back to current stable for hotfix release
This will be reverted back after the release

Revert "feat: re-do comment UI to look like Boost for Reddit"

This reverts commit 03e0a13e1d.

Revert "build: add compose screenshot testing support"

This reverts commit 5992a5fa15.

Revert "feat: rework comment entry layout and enable upvote action"

This reverts commit 4904c76220.

Revert "feat: re-do comment upvote UI"

This reverts commit c32ed849d2.
2026-05-29 21:52:59 +05:30
Harsh Shandilya 03e0a13e1d feat: re-do comment UI to look like Boost for Reddit 2026-05-29 19:51:05 +05:30
Harsh Shandilya 5992a5fa15 build: add compose screenshot testing support 2026-05-29 19:44:16 +05:30
renovate[bot]andGitHub 6b84d5277b chore(deps): update dependency com.facebook:ktfmt to v0.63 2026-05-28 21:04:01 +00:00
renovate[bot]andGitHub f41c36c24c fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.9.0 2026-05-28 14:40:29 +00:00
renovate[bot]andGitHub 9b4449c890 chore(deps): update plugin dependencyanalysis to v3.14.0 2026-05-28 06:08:21 +00:00
Harsh Shandilya 4904c76220 feat: rework comment entry layout and enable upvote action 2026-05-28 00:12:13 +05:30
Harsh Shandilya c5a8dea7d2 Merge remote-tracking branches 'origin/renovate/com.diffplug.spotless-spotless-plugin-gradle-8.x' and 'origin/renovate/sentry'
* origin/renovate/com.diffplug.spotless-spotless-plugin-gradle-8.x:
  fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.6.0

* origin/renovate/sentry:
  fix(deps): update sentry to v8.43.0
2026-05-27 23:18:05 +05:30
renovate[bot]andGitHub 8930c0c5dc fix(deps): update sentry to v8.43.0 2026-05-27 17:47:05 +00:00
renovate[bot]andGitHub 5dc4afbf36 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.6.0 2026-05-27 17:47:01 +00:00
renovate[bot]andHarsh Shandilya c3ec92582f fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.5.0 2026-05-27 23:15:00 +05:30
renovate[bot]andGitHub b8ee16be84 chore(deps): update plugin ksp to v2.3.9 2026-05-26 21:55:32 +00:00
Harsh Shandilya c32ed849d2 feat: re-do comment upvote UI 2026-05-27 00:33:46 +05:30
Harsh Shandilya 997b37ccb0 feat: switch expand/collapse UI to match Lobsters 2026-05-27 00:24:06 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
976ed3b31a chore(deps): update plugin com.gradle.develocity to v4.4.2 (#1174)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| com.gradle.develocity | `4.4.1` → `4.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.gradle.develocity:com.gradle.develocity.gradle.plugin/4.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.gradle.develocity:com.gradle.develocity.gradle.plugin/4.4.1/4.4.2?slim=true)
|

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTQuMCIsInVwZGF0ZWRJblZlciI6IjQzLjE5NC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 15:16:49 +00:00
Harsh Shandilya 75863cdf94 refactor: move CommentEntry composable to CommentEntry.kt 2026-05-26 17:13:04 +05:30
GitHub Actions a3687d0b4c feat(release): start next development iteration 2026-05-26 08:56:03 +00:00
GitHub Actions 2b571be006 feat(release): bump version 2026-05-26 08:49:32 +00:00
renovate[bot]andGitHub 820744409e fix(deps): update aboutlibraries to v14.2.1 2026-05-26 03:45:25 +00:00
Harsh Shandilya 1b6ae68e0d Fix login session detection and comment timestamps
Only persist Lobsters login cookies after the WebView proves the
session is authenticated, and treat anonymous lobster_trap cookies as
logged out state. Also clear the WebView cookie jar on logout so the
embedded login flow cannot silently restore a prior session.

Simplify comment timestamps to match the current site markup by
tracking one timestamp plus an edited flag instead of fabricating a
creation time we no longer receive.
2026-05-23 13:04:38 +05:30
renovate[bot]andGitHub 8c67a874a5 chore(deps): update plugin dependencyanalysis to v3.13.0 2026-05-21 22:43:29 +00:00
renovate[bot]andGitHub 8296f58ad9 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.8.1 2026-05-21 18:11:24 +00:00
renovate[bot]andGitHub 1cd40a1383 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.8.0 2026-05-21 13:28:46 +00:00
Harsh Shandilya dce9dc33fe chore: refresh baseline profiles 2026-05-21 18:56:33 +05:30
Harsh Shandilya 12c98aa2c4 chore: fix some dependency health violations 2026-05-21 18:56:33 +05:30
renovate[bot]andGitHub bb05c46e57 chore(deps): update plugin dependencyanalysis to v3.12.2 2026-05-21 01:35:42 +00:00
renovate[bot]andGitHub 12f075a23d fix(deps): update sentry to v8.42.0 2026-05-20 20:58:51 +00:00
renovate[bot]andGitHub 5065889e27 chore(deps): update mindsers/changelog-reader-action action to v2.4.0 2026-05-20 18:48:06 +00:00
renovate[bot]andGitHub 39ea7c9c2e chore(deps): update plugin dependencyanalysis to v3.12.1 2026-05-20 14:31:57 +00:00
renovate[bot]andGitHub 6f972a8099 fix(deps): update dependency org.junit:junit-bom to v6.1.0 2026-05-19 22:17:27 +00:00
renovate[bot]andGitHub 4d8357c774 fix(deps): update androidx 2026-05-19 17:28:52 +00:00
renovate[bot]andGitHub c92be255d2 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.7.1 2026-05-19 14:40:26 +00:00
renovate[bot]andGitHub 007c303272 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.5.1 2026-05-16 01:37:53 +00:00
renovate[bot]andGitHub b922dfafee fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.7.0 2026-05-15 14:31:15 +00:00
renovate[bot]andGitHub 9fab85419c fix(deps): update dependency be.digitalia.compose.htmlconverter:htmlconverter to v1.1.1 2026-05-15 14:14:26 +00:00
renovate[bot]andGitHub 09bc8f8ff8 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.5.0 2026-05-15 01:03:30 +00:00
renovate[bot]andGitHub e248796c49 chore(deps): update plugin dependencyanalysis to v3.12.0 2026-05-14 22:09:07 +00:00
renovate[bot]andGitHub 9297799549 fix(deps): update metro to v1.1.1 2026-05-14 04:47:25 +00:00
renovate[bot]andGitHub 26fdbe0bed chore(deps): update plugin ksp to v2.3.8 2026-05-13 17:04:35 +00:00
renovate[bot]andGitHub 4faf965851 chore(deps): update gradle to v9.5.1 2026-05-12 14:57:06 +00:00
renovate[bot]andGitHub 62016180b9 chore(deps): update plugin dependencyanalysis to v3.11.0 2026-05-12 04:47:11 +00:00
Harsh Shandilya 875b41d0bf fix: default comment count to 1 instead of 0 2026-05-12 08:53:33 +05:30
Harsh Shandilya 052cce6a05 chore: update changelog 2026-05-10 17:12:31 +05:30
Harsh ShandilyaandGitHub 54697ce4ca feat: migrate from unsupported JSON endpoints to parsing HTML (#1162) 2026-05-10 17:01:26 +05:30
Harsh Shandilya 378f140d1a fix(api): remove now unused test dependencies 2026-05-10 15:02:14 +05:30
Harsh Shandilya 00f0e27a0b feat(api): parse tags from html 2026-05-10 15:02:14 +05:30
Harsh Shandilya e21759e6f1 feat(api): parse user profiles from html 2026-05-10 15:01:47 +05:30
Harsh Shandilya 0d88a7cf78 feat(api): parse post details from html 2026-05-10 15:01:45 +05:30
Harsh Shandilya 87fe06a419 feat(api): parse post lists from html 2026-05-10 15:01:35 +05:30
Harsh Shandilya fcdc9db79c feat(api): add kspoon retrofit integration 2026-05-10 14:53:54 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
943398bb67 fix(deps): update dependency com.fleeksoft.ksoup:ksoup to v0.2.6 (#1161)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.fleeksoft.ksoup:ksoup](https://redirect.github.com/fleeksoft/ksoup)
| `0.2.4` → `0.2.6` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.fleeksoft.ksoup:ksoup/0.2.6?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.fleeksoft.ksoup:ksoup/0.2.4/0.2.6?slim=true)
|

---

### Release Notes

<details>
<summary>fleeksoft/ksoup (com.fleeksoft.ksoup:ksoup)</summary>

###
[`v0.2.6`](https://redirect.github.com/fleeksoft/ksoup/releases/tag/0.2.6)

[Compare
Source](https://redirect.github.com/fleeksoft/ksoup/compare/0.2.5...0.2.6)

- Updated build + CI (Gradle **9.3.1**, workflow refresh, cleaner build
scripts)
- Dependency upgrades: Kotlin **2.3.10**, AGP **8.13.2**, Ktor
**3.4.0**, plus kotlinx/Okio/Dokka updates
- Improved KMP/JS exports (moved to platform-agnostic `@KmpJsExport`)
- Aligned code  with **jsoup 1.22.1**
- Better HTML/XML parsing reliability + expanded fuzz/integration test
coverage

#### New Contributors

- [@&#8203;peacefulprogram](https://redirect.github.com/peacefulprogram)
made their first contribution in
[#&#8203;124](https://redirect.github.com/fleeksoft/ksoup/pull/124)

**Full Changelog**:
<https://github.com/fleeksoft/ksoup/compare/0.2.5...0.2.6>

###
[`v0.2.5`](https://redirect.github.com/fleeksoft/ksoup/releases/tag/0.2.5)

[Compare
Source](https://redirect.github.com/fleeksoft/ksoup/compare/0.2.4...0.2.5)

#### 🛠️ Build & Dependencies

- Kotlin `2.2.0`, Ktor `3.2.2`, Okio `3.16.0`, kotlinx-io `0.8.0`
- Gradle Wrapper updated to `8.14.3`
- Removed deprecated: `kotlin.native.ignoreIncorrectDependencies`

#### 🔬 CI & Compatibility

- GitHub Actions matrix: Java `17`, `21`, `24`
- Expanded test coverage: unit, concurrency, edge cases

#### ⚙️ Enhancements

- Improved DOM performance with `childNodes` cache
- Refactored iterators, attribute, and parser logic
- Build cleanup and code simplification

#### 🐛 Bug Fixes

- Base URI resolution, text node whitespace, stream reader edge cases
- Maven publish ordering
-  Resolved **Bug
[#&#8203;122](https://redirect.github.com/fleeksoft/ksoup/issues/122)**

#### 📚 Documentation

- Deprecated `PseudoTextElement` → use `Element#selectNodes`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTkuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE1OS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 18:41:58 +00:00
Harsh Shandilya fc67bda7cb refactor: remove jsoup dependency 2026-05-09 23:56:40 +05:30
Harsh Shandilya 62a71a309b refactor(api): parse search results with ksoup 2026-05-09 23:56:29 +05:30
Harsh Shandilya 3b0a0e3289 refactor(api): parse reply forms with ksoup 2026-05-09 23:56:24 +05:30
Harsh Shandilya 1274897eac refactor(api): parse csrf token with ksoup 2026-05-09 23:56:19 +05:30
Harsh Shandilya 017f970f61 test(api): characterize html converters 2026-05-09 23:55:33 +05:30
Harsh Shandilya bda3c4c26c feat(common): add an ugly as sin test UI for upvote/unvote 2026-05-09 23:55:03 +05:30
Harsh Shandilya c14f529f5e refactor(api): ensure correct ordering for Retrofit converters 2026-05-09 23:55:01 +05:30
Harsh Shandilya 0ff78e5b00 test(api): cover authenticated Lobsters facade 2026-05-09 23:54:50 +05:30
Harsh Shandilya de966de4ec feat(api): add authenticated Lobsters facade 2026-05-09 23:54:43 +05:30
Harsh Shandilya 8a1a64d173 feat(api): add raw authenticated Lobsters endpoints 2026-05-09 18:42:12 +05:30
Harsh Shandilya d1e5df8bc4 feat(api): parse Lobsters reply form 2026-05-09 18:42:12 +05:30
renovate[bot]andGitHub d80ccfe284 chore(deps): update actions/dependency-review-action action to v5 2026-05-09 01:59:35 +00:00
renovate[bot]andGitHub c2cf007b34 fix(deps): update haze to v2.0.0-alpha02 2026-05-08 21:25:45 +00:00
renovate[bot]andGitHub 9cd9d3830a fix(deps): update kotlinx-coroutines monorepo to v1.11.0 2026-05-08 14:01:43 +00:00
renovate[bot]andGitHub 38052b359e fix(deps): update aboutlibraries to v14.2.0 2026-05-07 08:57:12 +00:00
renovate[bot]andGitHub 96151df57f fix(deps): update dependency com.slack.lint.compose:compose-lint-checks to v1.4.3 2026-05-07 01:35:51 +00:00
renovate[bot]andGitHub a141573442 fix(deps): update sentry to v8.41.0 2026-05-06 22:31:41 +00:00
renovate[bot]andGitHub ac537183fb fix(deps): update androidx 2026-05-06 17:16:29 +00:00
renovate[bot]andGitHub 9faac26d57 fix(deps): update agp to v9.2.1 2026-05-05 18:18:26 +00:00
renovate[bot]andGitHub 03baf5edc1 fix(deps): update aboutlibraries to v14.1.0 2026-05-01 19:03:01 +00:00
renovate[bot]andGitHub 2b8a245481 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.6.0 2026-04-30 20:01:43 +00:00
renovate[bot]andGitHub 2d88ebafa3 chore(deps): update plugin dependencyanalysis to v3.10.0 2026-04-30 04:35:12 +00:00
Harsh Shandilya a1f0c79172 fix: bump Haze to 2.0.0-alpha01 2026-04-30 10:04:00 +05:30
renovate[bot]andGitHub 039c084967 chore(deps): update gradle to v9.5.0 2026-04-28 14:01:48 +00:00
renovate[bot]andGitHub a1ae8af8a8 fix(deps): update metro to v1.0.0 2026-04-28 05:31:30 +00:00
renovate[bot]andGitHub ad269f9edc fix(deps): update kotlinx-coroutines monorepo to v1.11.0-rc02 2026-04-27 17:31:09 +00:00
renovate[bot]andGitHub 7bf21ba23e fix(deps): update dependency org.junit:junit-bom to v6.1.0-rc1 2026-04-25 13:37:29 +00:00
renovate[bot]andGitHub 12226b48e8 chore(deps): update plugin poko to v0.22.1 2026-04-25 05:50:07 +00:00
renovate[bot]andGitHub 1d3ba7c0de fix(deps): update metro to v1.0.0-rc4 2026-04-25 00:46:14 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Harsh Shandilya
6e779cd438 fix(deps): update metro to v1.0.0-rc3 (#1148)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [dev.zacsweers.metro](https://redirect.github.com/ZacSweers/metro) |
`1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel-compose](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel-compose/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel-compose/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel/1.0.0-RC2/1.0.0-RC3?slim=true)
|
|
[dev.zacsweers.metro:metrox-android](https://redirect.github.com/ZacSweers/metro)
| `1.0.0-RC2` → `1.0.0-RC3` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-android/1.0.0-RC3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-android/1.0.0-RC2/1.0.0-RC3?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>ZacSweers/metro (dev.zacsweers.metro)</summary>

###
[`v1.0.0-RC3`](https://redirect.github.com/ZacSweers/metro/blob/HEAD/CHANGELOG.md#100-RC3)

[Compare
Source](https://redirect.github.com/ZacSweers/metro/compare/1.0.0-RC2...1.0.0-RC3)

*2026-04-23*

This is the third release candidate for Metro 1.0!

This means that its *runtime* APIs (`runtime`, `metrox` artifacts,
Gradle plugin, etc) will be API stable unless annotated with an
experimental annotation.

*Changes since RC2*

##### `enableFunctionProviders` enabled by default

This release promotes `enableFunctionProviders` (i.e. `() -> T` syntax
for providers) to stable, enables it by default, and marks usage of
Metro's native `Provider` as a warning.

This may require some migration in existing codebases. To help with
this, there's a [new section in
docs](https://zacsweers.github.io/metro/latest/adoption/#migrating-providert-to-function-syntax)
with information as well as a helper script: adoption docs guidance here
too.

##### New

- Add a new `desugaredProviderSeverity` option (default: `WARN`) that
reports a diagnostic when `Provider<T>` is used instead of the preferred
`() -> T` form. Set this to `NONE` to disable the warning during
migration, or `ERROR` to enforce the new style. Automatically treated as
`NONE` when `enableFunctionProviders` is disabled.
- **\[FIR]** Add diagnostic checks against providing intrinsic types
(`Provider`, `Lazy`, etc.) from `@Provides` declarations.
- **\[Gradle]** Introduce a new `compilerOptions {}` DSL for free Metro
compiler options and flags.
- **\[Gradle]** Add `IDE_WARN` and `IDE_ERROR` members to
`DiagnosticSeverity` to allow configuring some diagnostics to *only* run
in IDE sessions. Useful for diagnostics you only want to surface to
readers in the IDE without emitting compiler warnings in real (CLI)
compilations.

##### Enhancements

- **\[FIR]** Add a new diagnostic for ambiguous inject constructors.
Namely, cases where a class is annotated with `@Inject`, defines a
secondary constructor but no primary constructor. This is ambiguous,
metro now asks you to pick a lane.
- **\[FIR]** Add a new diagnostic for check against `private`
contributions.
- **\[FIR]** When rendering diagnostics in the IDE, use short names for
classes since they are shown in context already.
- **\[JVM/JS]** Generate `@JvmStatic` and `@JsStatic` annotations onto
static-ish functions for better staticization on those platforms.
- **\[docs]** Migrate doc site to Zensical. Works the same, fresh-ish
coat of paint!

##### Fixes

- **\[FIR]** Fix not recognizing `FirDeclarationOrigin.Precompiled`
origins when checking resolved default binding types. Previously we only
considered `FirDeclarationOrigin.Library`, but incremental compilation
uses `FirDeclarationOrigin.Precompiled` to differentiate. This would
result in misreads of default binding types in some cases during IC.
- **\[FIR]** When merging `@ContributesTo` types to graph supertypes,
add the original interface as well. This ensures they are visible in
ObjC exports, as the metro-generated contribution interfaces are
normally excluded.
- **\[IR]** Fix secondary inject constructors support when
`generateContributionProviders` is enabled.
- **\[IR]** Fix implicit class key lookup for map keys on
source-declared `@Binds` declarations.
- **\[IR]** Fix `implementsProviderType()` check in the compiler to only
exactly match `Function0` types when `enableFunctionProviders` is
enabled.
- **\[IR]** Set `thisGraphInstance` field types as the graph impl type
to avoid a Wasm issue.
- **\[interop]** Fix `@ContributesSubcomponent.Factory` interop with
square/anvil.
- **\[interop]** Fix `@MergeSubcomponent.Factory` interop with
zacsweers/anvil (anvil-ksp).
- **\[docs]** Fix source links in Dokka API docs.
- **\[docs]** Don't publish `**.internal.**` APIs in Dokka API docs.

##### Changes

- `enableFunctionProviders` (i.e. `() -> T` syntax for providers) is now
enabled by default. Previously this required opting in. The
function-syntax form is now the **recommended** way to declare provider
dependencies; `Provider<T>` is still supported but treated as a
desugared alternative and a **warning** by default, similar to if you
were to use `Function0` instead of `() -> T` syntax for functions. See
the [metro-intrinsics](docs/metro-intrinsics.md) docs for more details.
- **\[IR]** Remove deprecated `indexInOldValueParameters` use in IR for
better `2.4.0`+ support.
- **\[Gradle]** Promote `enableFunctionProviders` to stable.
- **\[Gradle]** Remove deprecated `useAssistedParamNamesAsIdentifiers`
property.
- **\[Gradle]** Remove `deduplicateInjectedParams` property.
- **\[Gradle]** Remove `enableKlibParamsCheck` property, use the new
`compilerOptions` API.
- **\[Gradle]** Remove `enableFullBindingGraphValidation` property, use
the new `compilerOptions` API.
- **\[Gradle]** Remove `enableGraphImplClassAsReturnType` property, use
the new `compilerOptions` API.
- **\[Gradle]** Remove `shrinkUnusedBindings` property, use the new
`compilerOptions` API.
- **\[metrox-android]** Change `MetroAppComponentProviders` accessor
multibindings to expose function types instead of `Provider` types.
- **\[metrox-viewmodel]** Change `MetroViewModelFactory` and
`MetroViewModelMultibindings` accessor multibindings to expose function
types instead of `Provider` types.
- Support Kotlin `2.4.0-Beta2`.
- Update embedded androidx.tracing to `2.0.0-alpha06`.

##### Contributors

Special thanks to the following contributors for contributing to this
release!

- [@&#8203;anddani](https://redirect.github.com/anddani)
- [@&#8203;jonamireh](https://redirect.github.com/jonamireh)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzkuNyIsInVwZGF0ZWRJblZlciI6IjQzLjEzOS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-04-23 18:12:19 +00:00
Harsh Shandilya 21ba2c3869 Merge remote-tracking branches 'origin/renovate/sentry' and 'origin/renovate/kotlin-monorepo'
* origin/renovate/sentry:
  fix(deps): update sentry

* origin/renovate/kotlin-monorepo:
  fix(deps): update kotlin monorepo to v2.3.21
2026-04-23 23:26:44 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
61d8c4e4e0 fix(deps): update kotlin and compose to v2.3.21 (#1145)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [org.jetbrains.kotlin.plugin.serialization](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.20/2.3.21?slim=true)
|
| [org.jetbrains.kotlin.plugin.compose](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin/2.3.20/2.3.21?slim=true)
|
| [org.jetbrains.kotlin:kotlin-gradle-plugin](https://kotlinlang.org/)
([source](https://redirect.github.com/JetBrains/kotlin)) | `2.3.20` →
`2.3.21` |
![age](https://developer.mend.io/api/mc/badges/age/maven/org.jetbrains.kotlin:kotlin-gradle-plugin/2.3.21?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.jetbrains.kotlin:kotlin-gradle-plugin/2.3.20/2.3.21?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>JetBrains/kotlin
(org.jetbrains.kotlin.plugin.serialization)</summary>

###
[`v2.3.21`](https://redirect.github.com/JetBrains/kotlin/blob/HEAD/ChangeLog.md#2321)

##### Backend. Wasm

- [`KT-84610`](https://youtrack.jetbrains.com/issue/KT-84610) \[Wasm]
Failed to compile klibs in IC mode

##### Compiler

- [`KT-84566`](https://youtrack.jetbrains.com/issue/KT-84566) Prevent
launching Default dispatcher threads from IJ SDK in kotlin compiler
- [`KT-85358`](https://youtrack.jetbrains.com/issue/KT-85358) Native:
roll back the workaround for KT-84678 once MapLibre has been properly
fixed
- [`KT-85626`](https://youtrack.jetbrains.com/issue/KT-85626)
`@JvmRecord` in commonMain breaks compileCommonMainKotlinMetadata with
"Cannot access 'java.lang.Record'"
- [`KT-85405`](https://youtrack.jetbrains.com/issue/KT-85405)
Postpone/Revert `DontIgnoreUpperBoundViolatedOnImplicitArguments`
- [`KT-84678`](https://youtrack.jetbrains.com/issue/KT-84678) K/N:
Undefined symbol from SPM-added ObjC frameworks when linking iOS target
- [`KT-85021`](https://youtrack.jetbrains.com/issue/KT-85021) False
positive SUBCLASS\_CANT\_CALL\_COMPANION\_PROTECTED\_NON\_STATIC error
in multi-module project

##### JavaScript

- [`KT-82395`](https://youtrack.jetbrains.com/issue/KT-82395) Support
top-level declarations from compiler plugins in JS incremental
compilation
- [`KT-84475`](https://youtrack.jetbrains.com/issue/KT-84475) K/JS:
false-positive exportability warnings in multi-module project
- [`KT-84633`](https://youtrack.jetbrains.com/issue/KT-84633) Kotlin/JS:
"Serializer for class not found" error when IR output granularity is
`whole-program`
- [`KT-85047`](https://youtrack.jetbrains.com/issue/KT-85047) Kotlin/JS:
`@JsStatic` on suspend fun of class companion generates incorrect d.ts
- [`KT-84517`](https://youtrack.jetbrains.com/issue/KT-84517) K/JS: bad
mappings data in outputted Kotlin stdlib source map

##### Libraries

- [`KT-71848`](https://youtrack.jetbrains.com/issue/KT-71848)
Kotlinx.metadata: Add `CompilerPluginData` into Km API

##### Native. C and ObjC Import

- [`KT-85399`](https://youtrack.jetbrains.com/issue/KT-85399)
Kotlin/Native: TypeCastException when casting ObjC Protocol MetaClass
with genericSafeCasts enabled
- [`KT-85508`](https://youtrack.jetbrains.com/issue/KT-85508) K/N:
TypeCastException when using nw\_parameters\_create\_secure\_tcp block
parameter on 2.3.20

##### Tools. Gradle

- [`KT-84729`](https://youtrack.jetbrains.com/issue/KT-84729) Update
Gradle plugin-publish version to enable configuration cache badge on
Gradle plugins portal

##### Tools. Gradle. Compiler plugins

- [`KT-85257`](https://youtrack.jetbrains.com/issue/KT-85257)
AGP/Compose: MergeMappingFileTask clears R8 artifacts due to
`@OutputDirectory` annotation on AGP 9.1+

##### Tools. Scripts

- [`KT-85105`](https://youtrack.jetbrains.com/issue/KT-85105) Scripts:
JVM backend internal error (IR lowering) when scratch file contains
anonymous object
- [`KT-85103`](https://youtrack.jetbrains.com/issue/KT-85103) Exception
while generating code when explain destructuring decls
- [`KT-84842`](https://youtrack.jetbrains.com/issue/KT-84842)
scriptCompilationClasspathFromContext behavior changed from 2.3.10 to
2.3.20
- [`KT-85029`](https://youtrack.jetbrains.com/issue/KT-85029) Kotlin
Scripting: ScriptDiagnostic reports "at null" instead of error location

##### Tools. Statistics (FUS)

- [`KT-85628`](https://youtrack.jetbrains.com/issue/KT-85628) KGP:
composite build FUS metrics fail on access of 'configurationTimeMetrics'

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzkuNyIsInVwZGF0ZWRJblZlciI6IjQzLjEzOS43IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 17:10:53 +00:00
renovate[bot]andGitHub ccdc75f7a3 fix(deps): update sentry 2026-04-23 16:58:32 +00:00
renovate[bot]andGitHub f1ae3c1a44 fix(deps): update kotlin monorepo to v2.3.21 2026-04-23 16:58:25 +00:00
renovate[bot]andGitHub aa32599a59 chore(deps): update plugin ksp to v2.3.7 2026-04-23 01:55:47 +00:00
renovate[bot]andGitHub a58edda23e fix(deps): update androidx 2026-04-22 20:32:10 +00:00
renovate[bot]andGitHub a833820be3 fix(deps): update agp to v9.2.0 2026-04-21 14:32:06 +00:00
renovate[bot]andGitHub 16cc3097b7 chore(deps): update r0adkll/upload-google-play action to v1.1.5 2026-04-21 02:41:06 +00:00
renovate[bot]andGitHub 49318b2766 chore(deps): update plugin dependencyanalysis to v3.9.0 2026-04-20 21:57:06 +00:00
renovate[bot]andGitHub c9a22df572 chore(deps): update r0adkll/upload-google-play action to v1.1.4 2026-04-20 19:43:40 +00:00
renovate[bot]andGitHub 13d368b86a chore(deps): update plugin com.gradle.develocity to v4.4.1 2026-04-20 13:13:55 +00:00
renovate[bot]andGitHub 4c14890170 fix(deps): update dependency org.jsoup:jsoup to v1.22.2 2026-04-20 08:40:49 +00:00
renovate[bot]andGitHub 43d1b51901 chore(deps): update plugin dependencyanalysis to v3.8.0 2026-04-19 09:09:49 +00:00
Harsh Shandilya 088fcf5a62 fix: bump dependencies 2026-04-18 21:26:19 +05:30
renovate[bot]andGitHub e0bb04ce4c fix(deps): update sentry to v8.39.1 2026-04-17 09:20:42 +00:00
renovate[bot]andGitHub b621fb0480 fix(deps): update sentry to v8.39.0 2026-04-16 20:36:33 +00:00
renovate[bot]andGitHub def7d1a727 fix(deps): update agp to v9.2.0-rc01 2026-04-13 17:03:34 +00:00
renovate[bot]andGitHub 24abd4171a chore(deps): update plugin modulegraphassert to v2.9.1 2026-04-12 14:00:50 +00:00
Harsh Shandilya 31be02c521 refactor: replace if with when 2026-04-12 14:25:49 +05:30
Harsh Shandilya 79f2b5d01c chore: rename ProGuard files to R8 2026-04-12 14:25:49 +05:30
Harsh Shandilya ae047d8a6d fix: remove obsoleted ProGuard rule 2026-04-12 14:10:13 +05:30
Harsh Shandilya 864b023302 refactor: remove useless twiddling of Main dispatcher 2026-04-12 14:01:59 +05:30
Harsh Shandilya 502eb751eb chore: add changelog entry for login 2026-04-12 13:55:59 +05:30
renovate[bot]andGitHub 210bc89346 chore(deps): update plugin dependencyanalysis to v3.7.0 (#1135) 2026-04-11 10:14:32 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9ecb0fa704 chore(deps): update actions/upload-artifact action to v7.0.1 (#1134)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | patch | `v7.0.0` → `v7.0.1` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>actions/upload-artifact (actions/upload-artifact)</summary>

###
[`v7.0.1`](https://redirect.github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v7.0.0...v7.0.1)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMTAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjExMC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 04:38:22 +00:00
Harsh Shandilya 635e33233b fix: move Lobsters URI creation out of class init
For some reason this is now too early and explodes
2026-04-11 08:17:54 +05:30
Harsh Shandilya 2cf53422bf fix: regenerate baseline profiles 2026-04-11 01:55:54 +05:30
Harsh Shandilya 80980af1c9 fix: move baseline profiles back to the emulators 2026-04-10 22:35:26 +05:30
Harsh Shandilya 3b5c4b3ddb fix(build): remove obsolete source set 2026-04-10 22:35:15 +05:30
Harsh Shandilya 84e36496d3 fix: downgrade Kotlin 2026-04-10 22:26:26 +05:30
Harsh Shandilya 3f714f41c3 fix: upgrade to latest everything 2026-04-10 21:44:31 +05:30
Harsh ShandilyaandGitHub e20c90297a fix: remove now-empty core-ktx library 2026-04-10 00:13:11 +05:30
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0ac8c91789 fix(deps): update androidx (#1128)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.lint:lint-gradle](https://developer.android.com/jetpack/androidx/releases/lint#1.0.0-alpha06)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.0.0-alpha05` → `1.0.0-alpha06` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.lint:lint-gradle/1.0.0-alpha06?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.lint:lint-gradle/1.0.0-alpha05/1.0.0-alpha06?slim=true)
|
|
[androidx.sqlite:sqlite](https://developer.android.com/jetpack/androidx/releases/sqlite#2.6.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.7.0-alpha02` → `2.7.0-alpha03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.sqlite:sqlite/2.7.0-alpha03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.sqlite:sqlite/2.7.0-alpha02/2.7.0-alpha03?slim=true)
|
|
[androidx.compose.runtime:runtime-saveable](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-saveable/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-saveable/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.paging:paging-compose](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.5.0-alpha01` → `3.5.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-compose/3.5.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-compose/3.5.0-alpha01/3.5.0-beta01?slim=true)
|
|
[androidx.paging:paging-common](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.5.0-alpha01` → `3.5.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-common/3.5.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-common/3.5.0-alpha01/3.5.0-beta01?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-navigation3](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.3.0-alpha10)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-navigation3/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive-layout](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive-layout/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.compose.material3.adaptive:adaptive](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#1.2.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.3.0-alpha09` → `1.3.0-alpha10` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha10?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3.adaptive:adaptive/1.3.0-alpha09/1.3.0-alpha10?slim=true)
|
|
[androidx.navigation3:navigation3-ui](https://developer.android.com/jetpack/androidx/releases/navigation3#1.1.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.1.0-rc01` → `1.1.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.navigation3:navigation3-ui/1.1.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.navigation3:navigation3-ui/1.1.0-rc01/1.1.0?slim=true)
|
|
[androidx.navigation3:navigation3-runtime](https://developer.android.com/jetpack/androidx/releases/navigation3#1.1.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.1.0-rc01` → `1.1.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.navigation3:navigation3-runtime/1.1.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.navigation3:navigation3-runtime/1.1.0-rc01/1.1.0?slim=true)
|
|
[androidx.compose.ui:ui-unit](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-unit/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-unit/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-tooling-preview](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-tooling](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-text](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-text/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-text/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui-graphics](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-graphics/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-graphics/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.ui:ui](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.runtime:runtime-annotation](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-annotation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-annotation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.runtime:runtime](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.material3:material3-window-size-class](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha16` → `1.5.0-alpha17` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha17?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3-window-size-class/1.5.0-alpha16/1.5.0-alpha17?slim=true)
|
|
[androidx.compose.material3:material3](https://developer.android.com/jetpack/androidx/releases/compose-material3#1.4.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha16` → `1.5.0-alpha17` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.material3:material3/1.5.0-alpha17?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.material3:material3/1.5.0-alpha16/1.5.0-alpha17?slim=true)
|
|
[androidx.compose.foundation:foundation-layout](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation-layout/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation-layout/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.foundation:foundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.animation:animation-core](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation-core/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation-core/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.compose.animation:animation](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.6)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-beta02` → `1.11.0-rc01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation/1.11.0-rc01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation/1.11.0-beta02/1.11.0-rc01?slim=true)
|
|
[androidx.annotation:annotation](https://developer.android.com/jetpack/androidx/releases/annotation#1.10.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.10.0-rc01` → `1.10.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.annotation:annotation/1.10.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.annotation:annotation/1.10.0-rc01/1.10.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMTAuMiIsInVwZGF0ZWRJblZlciI6IjQzLjExMC4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 12:16:38 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>msfjarvisHarsh Shandilya
d74b0c3f40 Centralize Android SDK version constants and wire benchmark through build-logic (#1129)
`benchmark` was hardcoding `compileSdk` because it applies
`com.android.test` + `kotlin-android` — neither of which pulls in
`AndroidCommonPlugin`. The SDK values were also duplicated across two
plugin files with no shared constant.

## Changes

- **New `AndroidConstants.kt`** — single source of truth for
`COMPILE_SDK = 37` and `TARGET_SDK = 37`
- **`AndroidCommonPlugin`** — replace literal `37` with `COMPILE_SDK`
- **`ApplicationPlugin`** — replace literal `37` with `TARGET_SDK`
- **`benchmark/build.gradle.kts`** — apply
`dev.msfjarvis.claw.android-common` so `compileSdk` is set by the
plugin; drop the hardcoded value. `TestExtension` implements
`CommonExtension`, so the plugin's `configure<CommonExtension>` block
applies cleanly.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-04-09 12:05:14 +00:00
renovate[bot]andGitHub 7040518fc7 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.4.0 2026-04-09 11:34:17 +00:00
renovate[bot]andGitHub e27a3582f8 fix(deps): update sentry to v8.38.0 2026-04-08 19:31:23 +00:00
renovate[bot]andGitHub f4b3f936ae fix(deps): update metro to v0.13.2 2026-04-07 01:29:44 +00:00
renovate[bot]andGitHub 199ddc9ca8 fix(deps): update aboutlibraries to v14.0.0 2026-04-06 20:39:38 +00:00
renovate[bot]andGitHub 792b8b343b fix(deps): update metro to v0.13.1 2026-04-06 08:50:46 +00:00
renovate[bot]andGitHub b10c87c33d fix(deps): update metro to v0.13.0 2026-04-05 05:17:12 +00:00
renovate[bot]andGitHub b6ed8e914e chore(deps): update gradle/actions digest to 50e97c2 2026-04-03 16:40:31 +00:00
Harsh Shandilya 3c6145c1f3 chore: refresh Lint baseline 2026-04-03 22:01:41 +05:30
Harsh Shandilya e1ca964185 feat(android): wire Login destination into nav graph and Settings 2026-04-03 21:52:39 +05:30
Harsh Shandilya 2e84ada707 feat(android): add login/logout rows to SettingsScreen 2026-04-03 21:52:39 +05:30
Harsh Shandilya 758f88e02f feat(android): add SettingsViewModel with login state 2026-04-03 21:52:39 +05:30
Harsh Shandilya eef58ab267 feat(android): add LoginScreen WebView composable 2026-04-03 21:52:39 +05:30
Harsh Shandilya 9d34cf6c2e feat(android): add Login nav destination 2026-04-03 21:52:38 +05:30
Harsh Shandilya bfb402d4ae feat(core): add SessionExpiryInterceptor to handle expired sessions 2026-04-03 21:52:38 +05:30
Harsh Shandilya 66c4dc78f4 feat(core): add LobstersCookieJar and wire into OkHttpModule 2026-04-03 21:52:38 +05:30
Harsh Shandilya 34e4836f2d feat(common): implement SessionCookieStore backed by SQLDelight 2026-04-03 21:52:38 +05:30
Harsh Shandilya c77d10942f feat(database): add SessionCookie table and queries 2026-04-03 21:52:38 +05:30
Harsh Shandilya 9c6c5e82cb feat(core): add SessionCookieStore interface 2026-04-03 21:45:52 +05:30
Harsh Shandilya dac23176ff chore: document tag migration removal 2026-04-03 18:15:26 +05:30
Harsh Shandilya 5c8174879f chore: ignore local Git worktrees 2026-04-03 18:15:26 +05:30
Harsh Shandilya 30a5bcc687 refactor(test): remove unnecessary advanceUntilIdle calls 2026-04-03 18:15:26 +05:30
Harsh Shandilya 30293d29f6 fix(build): remove datastore dependencies 2026-04-03 01:06:10 +05:30
Harsh Shandilya 0269a284ef refactor(test): use deterministic tag block dispatchers 2026-04-03 01:06:10 +05:30
Harsh Shandilya 19103c19b9 refactor(test): cover reactive tag block flows 2026-04-03 01:06:10 +05:30
Harsh Shandilya 96f2c78a4e refactor: remove tag datastore migration 2026-04-03 01:06:10 +05:30
GitHub Actions 4e44758c39 feat(release): start next development iteration 2026-04-02 09:29:53 +00:00
GitHub Actions a54940f47a feat(release): bump version 2026-04-02 09:24:42 +00:00
renovate[bot]andGitHub e61bb6bcd8 fix(deps): update metro to v0.12.1 2026-03-30 20:49:51 +00:00
Harsh Shandilya 25909e9949 fix(ci): stop the infinite loop 2026-03-27 23:37:59 +05:30
renovate[bot]andGitHub 6f5eecb694 chore(deps): update msfjarvis/compose-lobsters digest to 92aae74 2026-03-27 05:38:19 +00:00
renovate[bot]andGitHub 92aae743ab fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.3.0 2026-03-26 20:32:30 +00:00
renovate[bot]andGitHub bd1d8dd8a0 chore(deps): pin msfjarvis/compose-lobsters action to f3fb95d 2026-03-26 18:53:01 +00:00
renovate[bot]andGitHub f3fb95dc5f fix(deps): update sentry to v8.37.1 2026-03-26 13:43:43 +00:00
renovate[bot]andGitHub dc98d29fde fix(deps): update konvert to v4.5.0 2026-03-26 04:36:38 +00:00
renovate[bot]andGitHub 4234fda686 chore(deps): update plugin com.gradle.develocity to v4.4.0 2026-03-25 21:37:42 +00:00
renovate[bot]andGitHub f9122cf0b6 fix(deps): update androidx 2026-03-25 18:15:21 +00:00
renovate[bot]andGitHub 4c6c7f4995 fix(deps): update sentry to v8.37.0 2026-03-25 12:58:06 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Harsh Shandilya
ba59ba9015 fix(deps): update metro to v0.12.0 (#1116)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [dev.zacsweers.metro](https://redirect.github.com/ZacSweers/metro) |
`0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:dev.zacsweers.metro.gradle.plugin/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel-compose](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel-compose/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel-compose/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-viewmodel](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-viewmodel/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-viewmodel/0.11.4/0.12.0?slim=true)
|
|
[dev.zacsweers.metro:metrox-android](https://redirect.github.com/ZacSweers/metro)
| `0.11.4` → `0.12.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/dev.zacsweers.metro:metrox-android/0.12.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/dev.zacsweers.metro:metrox-android/0.11.4/0.12.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/266) for more information.

---

### Release Notes

<details>
<summary>ZacSweers/metro (dev.zacsweers.metro)</summary>

###
[`v0.12.0`](https://redirect.github.com/ZacSweers/metro/blob/HEAD/CHANGELOG.md#0120)

[Compare
Source](https://redirect.github.com/ZacSweers/metro/compare/0.11.4...0.12.0)

*2026-03-24*

##### New

#####
[**\[MEEP-2014\]**](https://redirect.github.com/ZacSweers/metro/discussions/2014)
Implicit class (map) keys

`MapKey.implicitClassKey` is a new API to allow for class-based map keys
to have their class parameters inferred on classes and `@Binds`
declarations.

This means that instead of redeclaring the annotated class in the key,
for example `@ViewModelKey`, you can now omit it and it will be
inferred.

```kotlin
@&#8203;ViewModelKey // <-- implicitly HomeViewModel::class
@&#8203;ContributesIntoMap(AppScope::class)
class HomeViewModel : ViewModel()
```

For classes, the implicit type is the annotated class. For `@Binds`
declarations, the receiver or single parameter are the implicit type.

You may still specify an explicit type. The compiler will warn you if
you specify a redundant one. If you need to suppress this diagnostic
temporarily to ease migration, you can add
`-Xwarning-level=MAP_KEY_REDUNDANT_IMPLICIT_CLASS_KEY:disabled` to your
compiler arguments.

The compiler will also error if you attempt to do this on `@Provides`
declarations, as those cannot be inferred.

Metro's first-party class-based map keys (like `@ClassKey`,
`@ViewModelKey`, etc.) now support this. Custom map keys can opt-in to
this by setting `MapKey.implicitClassKey` to true. See its doc for more
details.

```kotlin
@&#8203;MapKey(implicitClassKey = true)
annotation class ViewModelKey(val value: KClass<out ViewModel> = Nothing::class)
```

##### Misc

- **\[metrox-viewmodel]** Add `mingwX64` target.

##### Enhancements

- **\[FIR]** Add diagnostic to ensure map key annotations support
`FUNCTION` targets if they have a `@Target` annotation.
- **\[FIR]** Improve annotation argument matching to only use fully
resolved names or none at all. This helps avoid situations in the past
with interop where an argument at the same index and type but different
name could incorrectly be used.

##### Fixes

- **\[IR]** Fix `IllegalArgumentException` thrown when there are
multiple top-level functions with the same name but only one is
annotated with `@Inject`.
- **\[IR]** Only store a given binding container's own provider
factories in metro metadata. This resolves a bug where we could end up
duplicate-processing upstream providers in dynamic factories.
- **\[IR]** Fix a severity conversion compat function call for Kotlin
2.3.20+.
- **\[IR]** Ensure stable sort of output `SuspiciousUnusedMultibinding`
locations.
- **\[IR]** Don't skip dynamic keys inherited from parent graphs when
working with dynamic graphs.
- **\[IR]** Propagate `@OptionalBinding` annotations to generated static
factory creators if present.
- **\[IR]** Preserve nullability when remapping parameters with generic
layers.
- **\[Runtime]** `IntoSet` and `IntoMap` no longer have a `Target` of
`AnnotationTarget.CLASS`

##### Changes

- The Metro compiler now requires JVM 21+. Note that the runtime JVM
artifacts still target 11 unless otherwise documented.
- The Metro Gradle plugin now requires JVM 21+.
- The Metro Gradle plugin now requires Gradle 9+. Note that if you do
not use Kotlin Gradle DSL, it may work on older versions but YMMV.
- The Metro Gradle plugin now targets Kotlin `2.2`.
- `@Assisted.value` is formally deprecated now. See the
[docs](https://zacsweers.github.io/metro/latest/injection-types/#assisted-injection)
on why in case you missed this! TL;DR, Metro matches by parameter names
going forward.
- Metro's main branch now builds with Kotlin `2.3.20` but still targets
Kotlin 2.2 for its runtime artifacts and supports 2.2.20 all to 2.4.0
dev builds in its compiler.
- Remove deprecated `macosX64`, `tvosX64`, and `watchosX64` targets.
- Update Kotlin 2.4 compat support from `2.4.0-dev-539` to
`2.4.0-dev-2124`. This should support the upcoming IntelliJ 2026.1
release as well as the upcoming Kotlin `2.4.0-Beta1`.
- Test IntelliJ `2026.1 RC`.
- Update shaded Wire dependency to `6.1.0`.

##### Contributors

Special thanks to the following contributors for contributing to this
release!

- [@&#8203;Asapha](https://redirect.github.com/Asapha)
-
[@&#8203;ChristianKatzmann](https://redirect.github.com/ChristianKatzmann)
- [@&#8203;grandstaish](https://redirect.github.com/grandstaish)
- [@&#8203;jonamireh](https://redirect.github.com/jonamireh)
- [@&#8203;kevinguitar](https://redirect.github.com/kevinguitar)
- [@&#8203;svenjacobs](https://redirect.github.com/svenjacobs)
- [@&#8203;vRallev](https://redirect.github.com/vRallev)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
2026-03-25 05:58:37 +00:00
renovate[bot]andGitHub 4d4bbb440e chore(deps): update gradle/actions digest to 39e147c 2026-03-24 18:08:31 +00:00
renovate[bot]andGitHub f0ed3871df chore(deps): update gradle/actions action to v6 2026-03-23 18:09:03 +00:00
Harsh Shandilya d5170b201f fixup! fix(comments): refactor state handling to not mark first-visit as unread 2026-03-23 14:26:38 +05:30
Harsh Shandilya 77c635b824 chore: update changelog 2026-03-23 14:23:01 +05:30
Harsh Shandilya 83db9bb8a8 fix(comments): refactor state handling to not mark first-visit as unread 2026-03-23 14:18:26 +05:30
Charles LombardoandHarsh Shandilya 6512567990 LobstersCard: Use M3 button for save and comment
The previous buttons were difficult to hit on a device that used gesture navigation. This adds more horizontal and vertical padding to the card and uses the M3 button with a larger clickable region to make this more accessible.

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
2026-03-22 01:21:19 +05:30
Charles LombardoandHarsh Shandilya f244d35f74 LobstersCard: Increase max title lines to 2
I've frequently had to tap the comments button to read the full title of a post even though it could fit in a card within 2 lines. This prevents that issue in most cases.
2026-03-21 21:50:19 +05:30
renovate[bot]andGitHub 4b948cb7f4 chore(deps): update gradle to v9.4.1 2026-03-19 14:04:37 +00:00
renovate[bot]andGitHub 54455cc4c4 fix(deps): update dependency com.diffplug.spotless:spotless-plugin-gradle to v8.4.0 2026-03-18 18:12:25 +00:00
renovate[bot]andGitHub a4828040a8 fix(deps): update dependency io.sentry.android.gradle:io.sentry.android.gradle.gradle.plugin to v6.2.0 2026-03-18 13:20:53 +00:00
renovate[bot]andHarsh Shandilya 349f1fbc27 chore(deps): update dependency com.facebook:ktfmt to v0.62 2026-03-18 02:41:53 +05:30
renovate[bot]andGitHub 6ac339f56b fix(deps): update sentry to v8.36.0 2026-03-17 20:50:56 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e8cc694950 fix(deps): update aboutlibraries to v14.0.0-b03 (#1108)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| com.mikepenz.aboutlibraries.plugin.android | `14.0.0-b02` →
`14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz.aboutlibraries.plugin.android:com.mikepenz.aboutlibraries.plugin.android.gradle.plugin/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-compose-m3](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-compose-m3/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-compose-m3/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-core](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-core/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-core/14.0.0-b02/14.0.0-b03?slim=true)
|
|
[com.mikepenz:aboutlibraries-compose-core](https://redirect.github.com/mikepenz/AboutLibraries)
| `14.0.0-b02` → `14.0.0-b03` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.mikepenz:aboutlibraries-compose-core/14.0.0-b03?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.mikepenz:aboutlibraries-compose-core/14.0.0-b02/14.0.0-b03?slim=true)
|

---

### Release Notes

<details>
<summary>mikepenz/AboutLibraries
(com.mikepenz:aboutlibraries-compose-m3)</summary>

###
[`v14.0.0-b03`](https://redirect.github.com/mikepenz/AboutLibraries/releases/tag/14.0.0-b03)

[Compare
Source](https://redirect.github.com/mikepenz/AboutLibraries/compare/14.0.0-b02...14.0.0-b03)

##### 🚀 Features

- Migrate to ArtifactView API
- PR:
[#&#8203;1297](https://redirect.github.com/mikepenz/AboutLibraries/issues/1297)

##### 🧪 Tests

- Add Paparazzi preview tests and update dependencies
- PR:
[#&#8203;1298](https://redirect.github.com/mikepenz/AboutLibraries/issues/1298)

##### 💬 Other

- docs: add section about AGP 9.0 to README
- PR:
[#&#8203;1295](https://redirect.github.com/mikepenz/AboutLibraries/issues/1295)
- Bump actions/upload-artifact from 6 to 7
- PR:
[#&#8203;1307](https://redirect.github.com/mikepenz/AboutLibraries/issues/1307)
- docs: add description resource strings to keep file guidance
- PR:
[#&#8203;1308](https://redirect.github.com/mikepenz/AboutLibraries/issues/1308)
- Bump mikepenz/action-gh-release from 1 to 2
- PR:
[#&#8203;1309](https://redirect.github.com/mikepenz/AboutLibraries/issues/1309)

##### Contributors:

- [@&#8203;svenjacobs](https://redirect.github.com/svenjacobs),
[@&#8203;mikepenz](https://redirect.github.com/mikepenz),
[@&#8203;dependabot](https://redirect.github.com/dependabot)\[bot],
[@&#8203;jlee129589](https://redirect.github.com/jlee129589)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My42Ni40IiwidXBkYXRlZEluVmVyIjoiNDMuNjYuNCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 18:32:05 +00:00
Harsh Shandilya e3615795c0 fix: update dependencies to latest alphas 2026-03-17 23:49:23 +05:30
renovate[bot]andGitHub 43a2c2f4b3 chore(deps): update plugin poko to v0.22.0 2026-03-17 05:35:33 +00:00
renovate[bot]andGitHub a9f0569944 fix(deps): update sqldelight to v2.3.2 2026-03-17 00:59:07 +00:00
renovate[bot]andGitHub 5901b22fef fix(deps): update metro to v0.11.3 2026-03-16 22:04:39 +00:00
Harsh Shandilya 36213ad0a9 fixup! fix(deps): update kotlin and compose to v2.3.20 2026-03-16 20:58:12 +05:30
renovate[bot]andGitHub a10fe200e6 fix(deps): update kotlin and compose to v2.3.20 2026-03-16 10:01:50 +00:00
renovate[bot]andGitHub f6bfca6bbb fix(deps): update kotlinresult to v2.3.1 2026-03-14 14:30:41 +00:00
Harsh Shandilya 94b680192a chore: refresh feature graphics 2026-03-13 23:27:02 +05:30
renovate[bot]andGitHub a8dc8d3991 fix(deps): update sqldelight to v2.3.1 2026-03-13 04:49:06 +00:00
renovate[bot]andGitHub 46e55a5c26 fix(deps): update kotlinresult to v2.3.0 2026-03-13 00:42:08 +00:00
renovate[bot]andGitHub 820e422cfe fix(deps): update sentry to v8.35.0 2026-03-12 12:52:13 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cc4c022e4e fix(deps): update kotlinresult to v2.2.0 (#1100)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.michael-bull.kotlin-result:kotlin-result-coroutines](https://redirect.github.com/michaelbull/kotlin-result)
| `2.1.0` → `2.2.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.michael-bull.kotlin-result:kotlin-result-coroutines/2.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.michael-bull.kotlin-result:kotlin-result-coroutines/2.1.0/2.2.0?slim=true)
|
|
[com.michael-bull.kotlin-result:kotlin-result](https://redirect.github.com/michaelbull/kotlin-result)
| `2.1.0` → `2.2.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.michael-bull.kotlin-result:kotlin-result/2.2.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.michael-bull.kotlin-result:kotlin-result/2.1.0/2.2.0?slim=true)
|

---

### Release Notes

<details>
<summary>michaelbull/kotlin-result
(com.michael-bull.kotlin-result:kotlin-result-coroutines)</summary>

###
[`v2.2.0`](https://redirect.github.com/michaelbull/kotlin-result/releases/tag/2.2.0)

[Compare
Source](https://redirect.github.com/michaelbull/kotlin-result/compare/2.1.0...2.2.0)

##### Fallible Operations (`try*`)

Fallible collection operations now use a `try` prefix, inspired by
[Gleam's naming
convention](https://hexdocs.pm/gleam_stdlib/gleam/list.html#try_map) and
mirroring stdlib's collection API more closely. Several new operations
have also been added.

- Rename `mapResult*` functions to `tryMap*`, `mapAll` to `tryMap`,
`fold`/`foldRight` to `tryFold`/`tryFoldRight`
([`d052cf5`](https://redirect.github.com/michaelbull/kotlin-result/commit/d052cf5c4c2cad12f9bb04686d5ecbf82fbf8ec6))
- The previous names are deprecated with `@Deprecated` + `ReplaceWith`
for easy migration
- Add `tryFind`, `tryFindLast`, `tryForEach`, `tryForEachIndexed`,
`tryReduce`, `tryReduceIndexed` for `Iterable`
([`d2011f0`](https://redirect.github.com/michaelbull/kotlin-result/commit/d2011f025873286cc550cab4174a5280560a01cd))
- Add `tryFilter`, `tryAssociate`, `tryFlatMap`, `tryGroupBy`,
`tryPartition` + variants for `Iterable`
([`f32985e`](https://redirect.github.com/michaelbull/kotlin-result/commit/f32985e4c9bafa0f4356aaca710245889b5acc15))

##### Flow Extensions

The `kotlin-result-coroutines` module now includes extensions for the
`kotlinx-coroutines` `Flow` type, bringing the same Result-aware
operations available on `Iterable` to asynchronous streams. This was
born out of the discussion in
[#&#8203;78](https://redirect.github.com/michaelbull/kotlin-result/issues/78).

- Add `Flow` extensions for `kotlin-coroutines`
([`15d44a0`](https://redirect.github.com/michaelbull/kotlin-result/commit/15d44a0c86795de5aa1899d68db2f2991cd9324a))
  - Factory: `Result<Flow<V>, E>.toFlow()`
- Flow: `filterOk`, `filterErr`, `onEachOk`, `onEachErr`, `allOk`,
`allErr`, `anyOk`, `anyErr`, `countOk`, `countErr`, `partition`,
`combine`, `combineErr`
- Try: `tryFilter`, `tryFilterNot`, `tryMap`, `tryMapNotNull`,
`tryFlatMap`, `tryForEach`, `tryReduce`, `tryFold`, `tryFind`,
`tryFindLast`, `tryAssociate`, `tryAssociateBy`, `tryAssociateWith`,
`tryGroupBy`, `tryPartition`

##### Other

- Rename `onSuccess`/`onFailure` to `onOk`/`onErr`
([`a7c66c0`](https://redirect.github.com/michaelbull/kotlin-result/commit/a7c66c0d8750c2602c748007a32b613a4e3c3220))
- The new indexed variants (`onEachOkIndexed`, `onEachErrIndexed`)
proved that the `Success`/`Failure` naming becomes too verbose.
`Ok`/`Err` is more readable.
- Rename `filterValues`/`filterErrors` to `filterOk`/`filterErr`
([`87f4c36`](https://redirect.github.com/michaelbull/kotlin-result/commit/87f4c36364b849313644fb8465f6e9091983117d))
- Aligns naming with existing
`allOk`/`allErr`/`anyOk`/`anyErr`/`countOk`/`countErr` conventions
- Add `onEachOk`, `onEachOkIndexed`, `onEachErr`, `onEachErrIndexed` for
`Iterable<Result>`
([`a7c66c0`](https://redirect.github.com/michaelbull/kotlin-result/commit/a7c66c0d8750c2602c748007a32b613a4e3c3220))
- Add `combineErr` and `combineTo`/`combineErrTo` for `Iterable<Result>`
([`1133e00`](https://redirect.github.com/michaelbull/kotlin-result/commit/1133e00a8ac14d7d45a2c492f779c55ae5198f07),
[`c06eeb3`](https://redirect.github.com/michaelbull/kotlin-result/commit/c06eeb373f81f582da9c101629ba50afb6f558f3))
- Propagate nested `coroutineBinding` failures by
[@&#8203;dbottillo](https://redirect.github.com/dbottillo)
([`d952b52`](https://redirect.github.com/michaelbull/kotlin-result/commit/d952b52141608f653855883f1cc6274d121341a2))
- Closes
[#&#8203;128](https://redirect.github.com/michaelbull/kotlin-result/issues/128)
and
[#&#8203;133](https://redirect.github.com/michaelbull/kotlin-result/issues/133)
- Make `BindingException` public
([`549ed30`](https://redirect.github.com/michaelbull/kotlin-result/commit/549ed30d0a7bed36175c2f6abcc0d93c830c7642))
- Fixes `'internal' type accessed from public inline declaration`
warning that will become an error in Kotlin 2.4
- Update Kotlin to 2.3.10
([`12ceff7`](https://redirect.github.com/michaelbull/kotlin-result/commit/12ceff754cb5ace39720a45603ae5c0dacf87b67))
- Update Gradle to 9.4.0
([`468b1a5`](https://redirect.github.com/michaelbull/kotlin-result/commit/468b1a5509403dd759917b137793f4482ffb4588))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41OS4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 06:58:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Copilotmsfjarvis
70e7c305d0 fix(deps): update androidx (#1098)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[androidx.test.uiautomator:uiautomator](https://developer.android.com/jetpack/androidx/releases/test-uiautomator#2.3.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `2.4.0-beta01` → `2.4.0-beta02` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.test.uiautomator:uiautomator/2.4.0-beta02?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.test.uiautomator:uiautomator/2.4.0-beta01/2.4.0-beta02?slim=true)
|
|
[androidx.core:core-ktx](https://developer.android.com/jetpack/androidx/releases/core#1.18.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.17.0` → `1.18.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.core:core-ktx/1.18.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.core:core-ktx/1.17.0/1.18.0?slim=true)
|
|
[androidx.core:core](https://developer.android.com/jetpack/androidx/releases/core#1.18.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.17.0` → `1.18.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.core:core/1.18.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.core:core/1.17.0/1.18.0?slim=true)
|
|
[androidx.activity:activity-compose](https://developer.android.com/jetpack/androidx/releases/activity#1.13.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.12.4` → `1.13.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.activity:activity-compose/1.13.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.activity:activity-compose/1.12.4/1.13.0?slim=true)
|
|
[androidx.activity:activity](https://developer.android.com/jetpack/androidx/releases/activity#1.13.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.12.4` → `1.13.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.activity:activity/1.13.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.activity:activity/1.12.4/1.13.0?slim=true)
|
|
[androidx.compose.runtime:runtime-saveable](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-saveable/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-saveable/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.paging:paging-compose](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.4.1` → `3.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-compose/3.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-compose/3.4.1/3.4.2?slim=true)
|
|
[androidx.paging:paging-common](https://developer.android.com/jetpack/androidx/releases/paging#3.4.2)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `3.4.1` → `3.4.2` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.paging:paging-common/3.4.2?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.paging:paging-common/3.4.1/3.4.2?slim=true)
|
|
[androidx.datastore:datastore-preferences-core](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-preferences-core/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-preferences-core/1.2.0/1.2.1?slim=true)
|
|
[androidx.datastore:datastore-core](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-core/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-core/1.2.0/1.2.1?slim=true)
|
|
[androidx.datastore:datastore-preferences](https://developer.android.com/jetpack/androidx/releases/datastore#1.2.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.2.0` → `1.2.1` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.datastore:datastore-preferences/1.2.1?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.datastore:datastore-preferences/1.2.0/1.2.1?slim=true)
|
|
[androidx.compose.ui:ui-unit](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-unit/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-unit/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-tooling-preview](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling-preview/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-tooling](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-tooling/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-tooling/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-text](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-text/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-text/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui-graphics](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui-graphics/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui-graphics/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.ui:ui](https://developer.android.com/jetpack/androidx/releases/compose-ui#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.ui:ui/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.ui:ui/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.baselineprofile](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.baselineprofile:androidx.baselineprofile.gradle.plugin/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.baselineprofile:androidx.baselineprofile.gradle.plugin/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.benchmark:benchmark-macro-junit4](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.benchmark:benchmark-macro-junit4/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.benchmark:benchmark-macro-junit4/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.benchmark:benchmark-macro](https://developer.android.com/jetpack/androidx/releases/benchmark#1.4.1)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0-alpha03` → `1.5.0-alpha04` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.benchmark:benchmark-macro/1.5.0-alpha04?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.benchmark:benchmark-macro/1.5.0-alpha03/1.5.0-alpha04?slim=true)
|
|
[androidx.compose.runtime:runtime-annotation](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime-annotation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime-annotation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.runtime:runtime](https://developer.android.com/jetpack/androidx/releases/compose-runtime#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.runtime:runtime/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.runtime:runtime/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.foundation:foundation-layout](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation-layout/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation-layout/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.foundation:foundation](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.foundation:foundation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.foundation:foundation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.animation:animation-core](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation-core/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation-core/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.compose.animation:animation](https://developer.android.com/jetpack/androidx/releases/compose-animation#1.10.5)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.11.0-alpha06` → `1.11.0-beta01` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.compose.animation:animation/1.11.0-beta01?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.compose.animation:animation/1.11.0-alpha06/1.11.0-beta01?slim=true)
|
|
[androidx.collection:collection](https://developer.android.com/jetpack/androidx/releases/collection#1.6.0)
([source](https://cs.android.com/androidx/platform/frameworks/support))
| `1.5.0` → `1.6.0` |
![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.collection:collection/1.6.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.collection:collection/1.5.0/1.6.0?slim=true)
|

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/msfjarvis/compose-lobsters).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My41OS4wIiwidXBkYXRlZEluVmVyIjoiNDMuNTkuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msfjarvis <13348378+msfjarvis@users.noreply.github.com>
2026-03-11 20:46:56 +00:00
GitHub Actions e2242fce2e feat(release): start next development iteration 2026-03-10 05:26:48 +00:00
186 changed files with 17305 additions and 4833 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 435 KiB

+9
View File
@@ -53,6 +53,15 @@
],
"allowedVersions": "!/.*-1-8$/",
},
{
"matchManagers": [
"github-actions"
],
"matchDepNames": [
"msfjarvis/compose-lobsters"
],
"enabled": false,
},
],
"customManagers": [
{
@@ -33,7 +33,7 @@ runs:
persist-credentials: ${{ inputs.persist-credentials }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
- name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -53,7 +53,7 @@ runs:
echo "android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect" >> ~/.gradle/gradle.properties
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
with:
add-job-summary: always
cache-read-only: ${{ inputs.cache-read-only }}
+5 -5
View File
@@ -19,14 +19,14 @@ jobs:
runs-on: macos-26
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
fetch-depth: 0
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
dependency-graph: generate-and-submit
- name: Check changelog format
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
with:
path: ./CHANGELOG.md
validation_level: error
@@ -38,7 +38,7 @@ jobs:
- name: (Fail-only) Upload test report
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Test report
path: '**/build/reports/tests/**'
@@ -55,7 +55,7 @@ jobs:
persist-credentials: false
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
base-ref: refs/heads/main
head-ref: ${{ github.ref }}
@@ -66,7 +66,7 @@ jobs:
- check
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
cache-read-only: true
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: macos-26
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
token: ${{ secrets.POST_RELEASE_GH_TOKEN }}
fetch-depth: 0
@@ -53,7 +53,7 @@ jobs:
- name: Get changelog entry
id: changelog_reader
uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3
uses: mindsers/changelog-reader-action@1faaf50aa09d5793d9a100819973df801febfb31 # v2.4.0
with:
version: ${{ env.VERSION }}
path: ./CHANGELOG.md
@@ -122,7 +122,7 @@ jobs:
printf '%s' "${CHANGELOG}" >> distribution/whatsnew/whatsnew-en-GB
- name: Publish bundle to Google Play
uses: r0adkll/upload-google-play@935ef9c68bb393a8e6116b1575626a7f5be3a7fb # v1.1.3
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
mappingFile: android/bundle/mapping.txt
packageName: dev.msfjarvis.claw.android
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup build environment
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@main
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
with:
cache-read-only: true
fetch-depth: 0
@@ -66,7 +66,7 @@ jobs:
ORG_GRADLE_PROJECT_VERSION_CODE: ${{ steps.tramline.outputs.version_code }}
- name: Upload app bundle
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-aab
path: android/build/outputs/bundle/release/android-release.aab
+1
View File
@@ -248,3 +248,4 @@ distribution/
screenshots/
claw-export.json
.kotlin/
.worktrees/
+56 -1
View File
@@ -7,6 +7,57 @@ 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
### Changed
- 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
- Address fallout from Lobste.rs changing its HTML markup. This will keep happening :(
### Changed
- The comment expand/collapse indicators now match the Lobsters UI
## [1.64.0] - 2026-05-26
### Added
- Allow logging in to avoid rate limiting problems.
### Changed
- The migration path for tag data format changes in 1.61.0 has been removed.
- The app no longer uses the unsupported JSON endpoints on lobste.rs, instead relying
on parsing the HTML pages instead.
## [1.63.0] - 2026-04-02
### Fixed
- Comments are no longer indicated as unread on the first visit of a post
### Changed
- Post titles in the list view can now take up 2 lines to make ellipsizing less likely
- Action buttons now have a prominent background to make their touch target more obvious
- Action buttons now have additional padding to avoid accidental presses when interacting
with system gestures
Thanks to Charles Lombardo for contributing the action button UI changes.
## [1.62.0] - 2026-03-10
### Fixed
@@ -584,7 +635,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial Play Store release
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.62.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.62.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.61.0...v1.62.0
[1.61.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.60.0...v1.61.0
[1.60.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.59.0...v1.60.0
+9 -6
View File
@@ -20,6 +20,7 @@ plugins {
alias(libs.plugins.modulegraphassert)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.licensee)
alias(libs.plugins.screenshot)
alias(libs.plugins.kotlin.composeCompiler)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.dependencyAnalysis)
@@ -31,16 +32,13 @@ android {
defaultConfig.applicationId = "dev.msfjarvis.claw.android"
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildFeatures.compose = true
experimentalProperties["android.experimental.enableScreenshotTest"] = true
buildTypes.create("internal") {
matchingFallbacks += "release"
signingConfig = signingConfigs["debug"]
applicationIdSuffix = ".internal"
isDebuggable = true
}
buildTypes.getByName("release") {
baselineProfile.automaticGenerationDuringBuild =
project.providers.gradleProperty("genProf").isPresent
}
}
aboutLibraries.collect.gitHubApiToken = providers.environmentVariable("GITHUB_TOKEN").orNull
@@ -57,6 +55,7 @@ licensee {
ignoreDependencies("com.michael-bull.kotlin-result") { because("kotlin-result is ISC licensed") }
ignoreDependencies("org.commonmark") { because("Commonmark is BSD licensed") }
allowUrl("https://jsoup.org/license") { because("Jsoup is MIT licensed") }
allowUrl("https://opensource.org/license/MIT") { because("Ksoup is MIT licensed") }
}
metro { unusedGraphInputsSeverity = DiagnosticSeverity.ERROR }
@@ -97,9 +96,8 @@ dependencies {
implementation(libs.androidx.compose.ui.unit)
implementation(libs.androidx.annotation)
implementation(libs.androidx.collection)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.androidx.lifecycle.common)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -112,9 +110,11 @@ dependencies {
implementation(libs.androidx.work.runtime)
implementation(libs.eithernet)
implementation(libs.haze)
implementation(libs.haze.blur)
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)
@@ -142,5 +142,8 @@ dependencies {
runtimeOnly(libs.androidx.profileinstaller)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
addTestDependencies(project)
}
+101 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.1.0" type="baseline" client="gradle" dependencies="true" name="AGP (9.1.0)" variant="all" version="9.1.0">
<issues format="6" by="lint 9.2.1" type="baseline" client="gradle" dependencies="true" name="AGP (9.2.1)" variant="all" version="9.2.1">
<issue
id="Instantiatable"
@@ -12,6 +12,105 @@
column="21"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="69"
column="15"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" webViewRef?.loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="69"
column="35"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
errorLine1=" WebView(context)"
errorLine2=" ~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="78"
column="13"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.WebView is deprecated; consider using an alternative."
errorLine1=" WebView(context)"
errorLine2=" ~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="78"
column="21"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.getSettings is deprecated; consider using an alternative."
errorLine1=" settings.javaScriptEnabled = true"
errorLine2=" ~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="81"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
errorLine1=" webViewClient ="
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="82"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.setWebViewClient is deprecated; consider using an alternative."
errorLine1=" object : WebViewClient() {"
errorLine2=" ^">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="83"
column="19"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="118"
column="17"/>
</issue>
<issue
id="DeprecatedCall"
message="android.webkit.WebView.loadUrl is deprecated; consider using an alternative."
errorLine1=" loadUrl(LOGIN_URL)"
errorLine2=" ~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LoginScreen.kt"
line="118"
column="25"/>
</issue>
<issue
id="MemberExtensionConflict"
message="`KeepUntilTransitionsFinished` is defined both as a member in class `androidx.compose.animation.ExitTransition.Companion` and an extension in package `androidx.compose.animation`. The defined behavior for this is to use the member, but since the extension is explicitly imported into this file, there&apos;s a chance that this was not expected. (One common way this happens is for members to be added to a class after code was already written to use an extension)."
@@ -19,7 +118,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt"
line="253"
line="259"
column="49"/>
</issue>
-7
View File
@@ -1,7 +0,0 @@
-dontobfuscate
-keepattributes SourceFile, LineNumberTable
# Keep Glance ActionCallback classes for reflection
-keep class * implements androidx.glance.action.ActionCallback {
public <init>();
}
+2
View File
@@ -0,0 +1,2 @@
-dontobfuscate
-keepattributes SourceFile, LineNumberTable
+1
View File
@@ -28,6 +28,7 @@
android:name=".MainActivity"
android:configChanges="assetsPaths|colorMode|density|fontScale|fontWeightAdjustment|grammaticalGender|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|resourcesUnused|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:taskAffinity=""
android:exported="true">
<intent-filter>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,7 @@ import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@ContributesIntoMap(AppScope::class, binding<Activity>())
@ActivityKey(MainActivity::class)
@ActivityKey
@Inject
class MainActivity(
private val uriHandler: UriHandler,
@@ -59,26 +59,25 @@ class HottestPostsWidget : GlanceAppWidget() {
val posts =
when (val postsResult = appGraph.lobstersApi.getHottestPosts(1)) {
is ApiResult.Success -> {
val uiPosts = postsResult.value.map(LobstersPost::toUIPost)
val uiPosts = postsResult.value.posts.map(LobstersPost::toUIPost)
// Cache the posts for future use when network is unavailable
// Using try-catch to prevent widget rendering failures if caching fails
try {
val cachedPosts =
uiPosts.mapIndexed { index, post ->
CachedRemotePost(
shortId = post.shortId,
title = post.title,
url = post.url,
createdAt = post.createdAt,
commentCount = post.commentCount,
commentsUrl = post.commentsUrl,
submitterName = post.submitter,
tags = post.tags,
description = post.description,
userIsAuthor = post.userIsAuthor,
insertionOrder = index,
)
}
val cachedPosts = uiPosts.mapIndexed { index, post ->
CachedRemotePost(
shortId = post.shortId,
title = post.title,
url = post.url,
createdAt = post.createdAt,
commentCount = post.commentCount,
commentsUrl = post.commentsUrl,
submitterName = post.submitter,
tags = post.tags,
description = post.description,
userIsAuthor = post.userIsAuthor,
insertionOrder = index,
)
}
cachedRemotePostsRepository.savePosts(cachedPosts)
} catch (_: Exception) {
// Silently ignore caching failures - widget should still render
@@ -18,7 +18,7 @@ import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.BroadcastReceiverKey
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
@BroadcastReceiverKey(HottestPostsWidgetReceiver::class)
@BroadcastReceiverKey
@Inject
class HottestPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
@@ -18,7 +18,7 @@ import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.android.BroadcastReceiverKey
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
@BroadcastReceiverKey(SavedPostsWidgetReceiver::class)
@BroadcastReceiverKey
@Inject
class SavedPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
@@ -19,7 +19,6 @@ import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import dev.zacsweers.metro.Multibinds
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metrox.android.MetroAppComponentProviders
@@ -41,7 +40,7 @@ interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
@Multibinds
val workerProviders:
Map<KClass<out ListenableWorker>, Provider<InjectedWorkerFactory.WorkerInstanceFactory<*>>>
Map<KClass<out ListenableWorker>, () -> InjectedWorkerFactory.WorkerInstanceFactory<*>>
val workerFactory: InjectedWorkerFactory
@@ -22,17 +22,16 @@ import okhttp3.Request
class SentryConfigurationPlugin : AppPlugin {
override fun apply(application: Application) {
SentryAndroid.init(application) { options ->
options.beforeSend =
SentryOptions.BeforeSendCallback { event, hint ->
val request = hint.getAs(OKHTTP_REQUEST, Request::class.java)
options.beforeSend = SentryOptions.BeforeSendCallback { event, hint ->
val request = hint.getAs(OKHTTP_REQUEST, Request::class.java)
// Drop all OkHttp errors that are not about Lobsters specifically.
if (request != null && !LobstersApi.BASE_URL.contains(request.url.host)) {
null
} else {
event
}
// Drop all OkHttp errors that are not about Lobsters specifically.
if (request != null && !LobstersApi.BASE_URL.contains(request.url.host)) {
null
} else {
event
}
}
}
}
}
@@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import dev.chrisbanes.haze.HazeDefaults
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.blur.HazeBlurDefaults
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
@@ -62,6 +62,7 @@ fun ClawNavigationBar(
),
label = "",
content = {
val surfaceColor = MaterialTheme.colorScheme.surface
FloatingNavigationBar(
tonalElevation = 16.dp,
shape = MaterialTheme.shapes.extraLarge,
@@ -70,18 +71,15 @@ fun ClawNavigationBar(
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.clip(MaterialTheme.shapes.extraLarge)
.hazeEffect(
hazeState,
style =
HazeStyle(
backgroundColor = MaterialTheme.colorScheme.surface,
tints = emptyList(),
blurRadius = 24.dp,
noiseFactor = 0f,
),
),
containerColor =
if (HazeDefaults.blurEnabled()) Color.Transparent else MaterialTheme.colorScheme.surface,
.hazeEffect(hazeState) {
blurEffect {
backgroundColor = surfaceColor
colorEffects = emptyList()
blurRadius = 24.dp
noiseFactor = 0f
}
},
containerColor = if (HazeBlurDefaults.blurEnabled()) Color.Transparent else surfaceColor,
) {
items.forEach { navItem ->
val isSelected = currentNavKey == navItem.navKey
@@ -33,6 +33,13 @@ interface NonStackable
@Parcelize @Serializable data class User(val username: String) : NavKey, Parcelable, NonStackable
@Parcelize
@Serializable
data class Reply(
val postId: String,
val commentId: String,
) : NavKey, Parcelable
@Parcelize @Serializable data object Search : NavKey, Parcelable
@Parcelize @Serializable data object Settings : NavKey, Parcelable
@@ -41,6 +48,8 @@ interface NonStackable
@Parcelize @Serializable data object TagFiltering : NavKey, Parcelable
@Parcelize @Serializable data object Login : NavKey, Parcelable
enum class AppDestinations(
val icon: ImageVector,
val label: String,
@@ -63,8 +63,10 @@ import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Login
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.NonStackable
import dev.msfjarvis.claw.android.ui.navigation.Reply
import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.Search
import dev.msfjarvis.claw.android.ui.navigation.SentryNavigation3Traced
@@ -73,7 +75,9 @@ import dev.msfjarvis.claw.android.ui.navigation.TagFiltering
import dev.msfjarvis.claw.android.ui.navigation.TopLevelDestination
import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.android.viewmodel.SettingsViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.reply.ReplyScreen
import dev.msfjarvis.claw.common.tags.TagFilterViewModel
import dev.msfjarvis.claw.common.tags.TagList
import dev.msfjarvis.claw.common.user.UserProfile
@@ -93,6 +97,7 @@ fun LobstersPostsScreen(
deepLinkDestination: NavKey? = null,
clearDeepLink: () -> Unit = {},
viewModel: ClawViewModel = metroViewModel(),
settingsViewModel: SettingsViewModel = metroViewModel(),
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
) {
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
@@ -115,6 +120,8 @@ fun LobstersPostsScreen(
val savedPostsCount by viewModel.savedPostsCount.collectAsStateWithLifecycle(0L)
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(persistentSetOf())
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val username by settingsViewModel.username.collectAsStateWithLifecycle(null)
LaunchedEffect(deepLinkDestination) {
if (deepLinkDestination != null) {
@@ -183,7 +190,7 @@ fun LobstersPostsScreen(
NavDisplay(
backStack = backStack,
modifier = modifier.hazeSource(hazeState),
sceneStrategy = listDetailStrategy,
sceneStrategies = listOf(listDetailStrategy),
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()),
onBack = {
if (popBackStack(backStack) == null) {
@@ -244,6 +251,18 @@ fun LobstersPostsScreen(
postActions = commentsPostActions,
contentPadding = contentPadding,
openUserProfile = { navigateTo(backStack, User(it)) },
openReplyScreen = { postId, commentId ->
navigateTo(backStack, Reply(postId, commentId), allowStacking = true)
},
)
}
entry<Reply>(metadata = ListDetailSceneStrategy.extraPane()) { dest ->
ReplyScreen(
commentId = dest.commentId,
contentPadding = contentPadding,
postId = dest.postId,
onReplySubmitted = { popBackStack(backStack) },
modifier = Modifier.fillMaxSize(),
)
}
entry<User>(
@@ -276,6 +295,10 @@ fun LobstersPostsScreen(
uriHandler.openUri("https://github.com/msfjarvis/compose-lobsters")
},
openTagFiltering = { navigateTo(backStack, TagFiltering) },
openLoginScreen = { navigateTo(backStack, Login) },
onLogout = { settingsViewModel.logout() },
isLoggedIn = isLoggedIn,
username = username,
importPosts = viewModel::importPosts,
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
@@ -285,13 +308,16 @@ fun LobstersPostsScreen(
modifier = Modifier.fillMaxSize(),
)
}
entry<Login>(metadata = ListDetailSceneStrategy.extraPane()) {
LoginScreen(
onLoginSuccess = settingsViewModel::saveCookie,
popBackStack = { popBackStack(backStack) },
modifier = Modifier.fillMaxSize(),
)
}
entry<Search>(metadata = ListDetailSceneStrategy.extraPane()) {
setWebUri("https://lobste.rs/search")
SearchScreen(
viewModel = viewModel,
postActions = postActions,
contentPadding = contentPadding,
)
SearchScreen(postActions = postActions, contentPadding = contentPadding)
}
entry<AboutLibraries>(metadata = ListDetailSceneStrategy.extraPane()) {
LibrariesContainer(
@@ -332,10 +358,9 @@ fun navigateTo(
return
}
val existingEntry =
backStack.firstOrNull {
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
}
val existingEntry = backStack.firstOrNull {
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
}
if (existingEntry != null) {
backStack.remove(existingEntry)
@@ -0,0 +1,131 @@
/*
* 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.android.ui.screens
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
private const val LOGIN_URL = "https://lobste.rs/login"
private const val LOBSTERS_URL = "https://lobste.rs"
internal fun parseAuthenticatedUsername(usernameResult: String): String? {
return usernameResult.removeSurrounding("\"").takeUnless { it.isBlank() || it == "null" }
}
@Composable
fun LoginScreen(
onLoginSuccess: (String, String) -> Unit,
popBackStack: () -> Unit,
modifier: Modifier = Modifier,
) {
var isLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
Scaffold(modifier = modifier) { contentPadding ->
Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) {
if (hasError) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Failed to load login page.")
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
hasError = false
isLoading = true
webViewRef?.loadUrl(LOGIN_URL)
}
) {
Text("Retry")
}
}
} else {
AndroidView(
factory = { context ->
WebView(context)
.apply {
@SuppressLint("SetJavaScriptEnabled")
settings.javaScriptEnabled = true
webViewClient =
object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
isLoading = true
}
override fun onPageFinished(view: WebView, url: String) {
isLoading = false
if (!url.contains("/login")) {
@SuppressLint("DeprecatedCall")
view.evaluateJavascript(
"document.body && document.body.getAttribute('data-username')"
) { usernameResult ->
val username = parseAuthenticatedUsername(usernameResult)
if (username != null) {
val cookie = CookieManager.getInstance().getCookie(LOBSTERS_URL)
if (cookie != null) {
onLoginSuccess(cookie, username)
popBackStack()
}
}
}
}
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError,
) {
if (request.isForMainFrame) {
isLoading = false
hasError = true
}
}
}
loadUrl(LOGIN_URL)
}
.also { webViewRef = it }
},
modifier = Modifier.fillMaxSize(),
)
}
if (isLoading && !hasError) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.FilterList
@@ -54,11 +55,23 @@ import kotlinx.coroutines.launch
private const val JSON_MIME_TYPE = "application/json"
private const val HTML_MIME_TYPE = "application/html"
internal fun loggedInAccountText(username: String?): String {
return if (username.isNullOrBlank()) {
"Sign out of your lobste.rs account"
} else {
"Logged in as $username"
}
}
@Composable
fun SettingsScreen(
openLibrariesScreen: () -> Unit,
openRepository: () -> Unit,
openTagFiltering: () -> Unit,
openLoginScreen: () -> Unit,
onLogout: () -> Unit,
isLoggedIn: Boolean,
username: String?,
snackbarHostState: SnackbarHostState,
openInputStream: (Uri) -> InputStream?,
openOutputStream: (Uri) -> OutputStream?,
@@ -71,6 +84,37 @@ fun SettingsScreen(
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier.padding(contentPadding)) {
// Account Section
SectionHeader(title = "Account")
if (isLoggedIn) {
ListItem(
headlineContent = { Text("Log out") },
supportingContent = { Text(loggedInAccountText(username)) },
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = onLogout),
)
} else {
ListItem(
headlineContent = { Text("Log in with lobste.rs") },
supportingContent = { Text("Sign in to your lobste.rs account") },
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
modifier = Modifier.height(32.dp),
)
},
modifier = Modifier.clickable(onClick = openLoginScreen),
)
}
Spacer(modifier = Modifier.height(24.dp))
// Data Management Section
SectionHeader(title = stringResource(R.string.data_management))
ListItem(
@@ -343,6 +387,10 @@ private fun SettingsScreenPreview() {
openLibrariesScreen = {},
openRepository = {},
openTagFiltering = {},
openLoginScreen = {},
onLogout = {},
isLoggedIn = false,
username = null,
snackbarHostState = SnackbarHostState(),
openInputStream = { null },
openOutputStream = { null },
@@ -27,6 +27,7 @@ import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZ
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
import dev.msfjarvis.claw.android.paging.SearchPagingSource
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.toPostsResult
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
import dev.msfjarvis.claw.model.UIPost
@@ -38,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
@@ -48,9 +48,12 @@ 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(ClawViewModel::class)
@ViewModelKey
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
class ClawViewModel(
context: Context,
@@ -72,7 +75,9 @@ class ClawViewModel(
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = { pagingSourceFactory.create(api::getHottestPosts) },
pagingSourceFactory = {
pagingSourceFactory.create { page -> api.getHottestPosts(page).toPostsResult() }
},
)
.flow
.cachedIn(viewModelScope)
@@ -88,7 +93,9 @@ class ClawViewModel(
Pager(
config = PagingConfig(pageSize = PAGE_SIZE),
initialKey = STARTING_PAGE_INDEX,
pagingSourceFactory = { pagingSourceFactory.create(api::getNewestPosts) },
pagingSourceFactory = {
pagingSourceFactory.create { page -> api.getNewestPosts(page).toPostsResult() }
},
)
.flow
.cachedIn(viewModelScope)
@@ -168,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 =
@@ -0,0 +1,54 @@
/*
* 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.android.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.msfjarvis.claw.core.network.SessionCookieStore
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.binding
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@Inject
@ViewModelKey
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
class SettingsViewModel(
private val sessionCookieStore: SessionCookieStore,
private val webViewCookieStore: WebViewCookieStore,
) : ViewModel() {
val isLoggedIn =
sessionCookieStore
.isLoggedIn()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
val username =
sessionCookieStore
.username()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null,
)
fun logout() {
sessionCookieStore.clear()
webViewCookieStore.clearLobstersCookies()
}
fun saveCookie(cookie: String, username: String) {
sessionCookieStore.set(cookie, username)
}
}
@@ -0,0 +1,25 @@
/*
* 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.android.viewmodel
import android.webkit.CookieManager
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
interface WebViewCookieStore {
fun clearLobstersCookies()
}
@Inject
@ContributesBinding(AppScope::class)
class AndroidWebViewCookieStore : WebViewCookieStore {
override fun clearLobstersCookies() {
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
}
}
@@ -0,0 +1,72 @@
/*
* 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.android.viewmodel
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.core.network.SessionCookieStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.junit.jupiter.api.Test
class SettingsViewModelTest {
@Test
fun `logout clears persisted and webview sessions`() {
val sessionCookieStore = FakeSessionCookieStore()
val webViewCookieStore = FakeWebViewCookieStore()
val viewModel = SettingsViewModel(sessionCookieStore, webViewCookieStore)
viewModel.logout()
assertThat(sessionCookieStore.cleared).isTrue()
assertThat(webViewCookieStore.cleared).isTrue()
}
@Test
fun `saveCookie persists username`() {
val sessionCookieStore = FakeSessionCookieStore()
val viewModel = SettingsViewModel(sessionCookieStore, FakeWebViewCookieStore())
viewModel.saveCookie("cookie=value", "alice")
assertThat(sessionCookieStore.savedCookie).isEqualTo("cookie=value")
assertThat(sessionCookieStore.savedUsername).isEqualTo("alice")
}
private class FakeSessionCookieStore : SessionCookieStore {
var cleared = false
var savedCookie: String? = null
var savedUsername: String? = null
override fun get(): String? = savedCookie
override fun getUsername(): String? = savedUsername
override fun set(cookie: String, username: String) {
savedCookie = cookie
savedUsername = username
}
override fun clear() {
cleared = true
savedCookie = null
savedUsername = null
}
override fun isLoggedIn(): Flow<Boolean> = flowOf(savedUsername != null)
override fun username(): Flow<String?> = flowOf(savedUsername)
}
private class FakeWebViewCookieStore : WebViewCookieStore {
var cleared = false
override fun clearLobstersCookies() {
cleared = true
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
#
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
#
versioning-plugin.versionCode=16200
versioning-plugin.versionName=1.62.0
versioning-plugin.versionCode=16600
versioning-plugin.versionName=1.66.0
+6 -3
View File
@@ -8,23 +8,26 @@ import dev.msfjarvis.claw.gradle.addTestDependencies
plugins {
id("dev.msfjarvis.claw.kotlin-jvm")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.dependencyAnalysis)
alias(libs.plugins.metro)
}
dependencies {
api(libs.eithernet)
api(libs.kotlinx.serialization.core)
api(libs.okhttp.core)
api(libs.retrofit)
api(projects.model)
implementation(libs.eithernet.integration.retrofit)
implementation(libs.jsoup)
implementation(libs.ksoup)
implementation(libs.kspoon)
implementation(libs.retrofit.kotlinxSerializationConverter)
testImplementation(libs.eithernet.test.fixtures)
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.core)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlinx.datetime)
addTestDependencies(project)
}
@@ -0,0 +1,97 @@
/*
* 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.api
import com.slack.eithernet.ApiResult
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.zacsweers.metro.Inject
import java.io.IOException
import okhttp3.MultipartBody
private const val XML_HTTP_REQUEST = "XMLHttpRequest"
private const val XML_HTTP_REQUEST_DUPLICATE = "XMLHttpRequest, XMLHttpRequest"
private const val ACCEPT_ANY = "*/*"
@Inject
class AuthenticatedLobstersApi(private val api: LobstersApi) {
suspend fun upvoteComment(commentId: String): ApiResult<Unit, Unit> {
return withCSRFToken { csrfToken ->
api.upvoteComment(commentId, csrfToken, XML_HTTP_REQUEST, part("reason", ""))
}
}
suspend fun unvoteComment(commentId: String): ApiResult<Unit, Unit> {
return withCSRFToken { csrfToken ->
api.unvoteComment(commentId, csrfToken, XML_HTTP_REQUEST, part("reason", ""))
}
}
suspend fun reply(commentId: String, storyId: String, comment: String): ApiResult<Unit, Unit> {
val referer = "${LobstersApi.BASE_URL}/s/$storyId"
return withCSRFToken { csrfToken ->
when (val formResponse = api.getReplyForm(commentId, csrfToken, XML_HTTP_REQUEST, referer)) {
is Success -> {
val form = formResponse.value
if (!form.isValid()) {
ApiResult.unknownFailure(IOException("Lobsters reply form was missing required fields"))
} else {
api.postReply(
csrfToken = csrfToken,
requestedWith = XML_HTTP_REQUEST_DUPLICATE,
referer = referer,
origin = LobstersApi.BASE_URL,
accept = ACCEPT_ANY,
authenticityToken = part("authenticity_token", form.authenticityToken),
storyId = part("story_id", form.storyId),
method = part("_method", form.method),
parentCommentShortId = part("parent_comment_short_id", form.parentCommentShortId),
comment = part("comment", comment),
commit = part("commit", "Post"),
)
}
}
is Failure -> formResponse.toUnitFailure()
}
}
}
private suspend fun withCSRFToken(
block: suspend (String) -> ApiResult<Unit, Unit>
): ApiResult<Unit, Unit> {
return when (val token = api.getCSRFToken()) {
is Success -> {
val value = token.value.value
if (value.isBlank()) {
ApiResult.unknownFailure(IOException("Lobsters CSRF token was empty"))
} else {
block(value)
}
}
is Failure -> token.toUnitFailure()
}
}
private fun ReplyForm.isValid(): Boolean =
authenticityToken.isNotBlank() &&
storyId.isNotBlank() &&
method.isNotBlank() &&
parentCommentShortId.isNotBlank()
private fun part(name: String, value: String): MultipartBody.Part =
MultipartBody.Part.createFormData(name, value)
private fun Failure<*>.toUnitFailure(): Failure<Unit> =
when (this) {
is Failure.ApiFailure -> ApiResult.apiFailure(error as? Unit)
is Failure.HttpFailure -> ApiResult.httpFailure(code, error as? Unit)
is Failure.NetworkFailure -> ApiResult.networkFailure(error)
is Failure.UnknownFailure -> ApiResult.unknownFailure(error)
}
}
@@ -7,31 +7,94 @@
package dev.msfjarvis.claw.api
import com.slack.eithernet.ApiResult
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.burnoo.kspoon.annotation.Selector
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import kotlinx.serialization.Serializable
import okhttp3.MultipartBody
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
@Serializable class PostsPage(@Selector("li.story") val posts: List<LobstersPost> = emptyList())
@Serializable class TagsPage(@Selector("ol.category_tags > li") val tags: List<Tag> = emptyList())
fun ApiResult<PostsPage, Unit>.toPostsResult(): ApiResult<List<LobstersPost>, Unit> =
when (this) {
is Success -> ApiResult.success(value.posts)
is Failure.ApiFailure -> ApiResult.apiFailure(error)
is Failure.HttpFailure -> ApiResult.httpFailure(code, error)
is Failure.NetworkFailure -> ApiResult.networkFailure(error)
is Failure.UnknownFailure -> ApiResult.unknownFailure(error)
}
/** Simple interface defining an API for lobste.rs */
interface LobstersApi {
@GET("page/{page}.json")
suspend fun getHottestPosts(@Path("page") page: Int): ApiResult<List<LobstersPost>, Unit>
@GET("page/{page}")
suspend fun getHottestPosts(@Path("page") page: Int): ApiResult<PostsPage, Unit>
@GET("newest/page/{page}.json")
suspend fun getNewestPosts(@Path("page") page: Int): ApiResult<List<LobstersPost>, Unit>
@GET("newest/page/{page}")
suspend fun getNewestPosts(@Path("page") page: Int): ApiResult<PostsPage, Unit>
@GET("s/{postId}.json")
@GET("s/{postId}")
suspend fun getPostDetails(@Path("postId") postId: String): ApiResult<LobstersPostDetails, Unit>
@GET("~{username}.json")
suspend fun getUser(@Path("username") username: String): ApiResult<User, Unit>
@GET("~{username}") suspend fun getUser(@Path("username") username: String): ApiResult<User, Unit>
@GET("/") suspend fun getCSRFToken(): ApiResult<CSRFToken, Unit>
@GET("tags.json") suspend fun getTags(): ApiResult<List<Tag>, Unit>
@GET("tags") suspend fun getTags(): ApiResult<TagsPage, Unit>
@Multipart
@POST("comments/{commentId}/upvote")
suspend fun upvoteComment(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Part reason: MultipartBody.Part,
): ApiResult<Unit, Unit>
@Multipart
@POST("comments/{commentId}/unvote")
suspend fun unvoteComment(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Part reason: MultipartBody.Part,
): ApiResult<Unit, Unit>
@GET("comments/{commentId}/reply")
suspend fun getReplyForm(
@Path("commentId") commentId: String,
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Header("Referer") referer: String,
): ApiResult<ReplyForm, Unit>
@Multipart
@POST("comments")
suspend fun postReply(
@Header("x-csrf-token") csrfToken: String,
@Header("x-requested-with") requestedWith: String,
@Header("Referer") referer: String,
@Header("Origin") origin: String,
@Header("Accept") accept: String,
@Part authenticityToken: MultipartBody.Part,
@Part storyId: MultipartBody.Part,
@Part method: MultipartBody.Part,
@Part parentCommentShortId: MultipartBody.Part,
@Part comment: MultipartBody.Part,
@Part commit: MultipartBody.Part,
): ApiResult<Unit, Unit>
companion object {
const val BASE_URL = "https://lobste.rs"
@@ -0,0 +1,14 @@
/*
* 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.api
data class ReplyForm(
val authenticityToken: String,
val storyId: String,
val method: String,
val parentCommentShortId: String,
)
@@ -6,18 +6,18 @@
*/
package dev.msfjarvis.claw.api.converters
import com.fleeksoft.ksoup.Ksoup
import dev.msfjarvis.claw.api.CSRFToken
import dev.msfjarvis.claw.api.LobstersApi
import java.lang.reflect.Type
import okhttp3.ResponseBody
import org.jsoup.Jsoup
import retrofit2.Converter
import retrofit2.Retrofit
object CSRFTokenConverter : Converter<ResponseBody, CSRFToken> {
override fun convert(value: ResponseBody): CSRFToken {
val token =
Jsoup.parse(value.string(), LobstersApi.BASE_URL)
Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
.select("meta[name=\"csrf-token\"]")
.firstOrNull()
?.attr("content")
@@ -30,8 +30,8 @@ object CSRFTokenConverter : Converter<ResponseBody, CSRFToken> {
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, CSRFToken> {
return CSRFTokenConverter
): Converter<ResponseBody, *>? {
return if (type == CSRFToken::class.java) CSRFTokenConverter else null
}
}
}
@@ -0,0 +1,40 @@
/*
* 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.api.converters
import com.fleeksoft.ksoup.Ksoup
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.ReplyForm
import java.lang.reflect.Type
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
object ReplyFormConverter : Converter<ResponseBody, ReplyForm> {
override fun convert(value: ResponseBody): ReplyForm {
val document = Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
fun inputValue(name: String): String =
document.select("input[name=\"$name\"]").firstOrNull()?.attr("value").orEmpty()
return ReplyForm(
authenticityToken = inputValue("authenticity_token"),
storyId = inputValue("story_id"),
method = inputValue("_method"),
parentCommentShortId = inputValue("parent_comment_short_id"),
)
}
object Factory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
return if (type == ReplyForm::class.java) ReplyFormConverter else null
}
}
}
@@ -6,23 +6,21 @@
*/
package dev.msfjarvis.claw.api.converters
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Element
import com.fleeksoft.ksoup.select.Elements
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.model.LobstersPost
import java.lang.reflect.Type
import okhttp3.ResponseBody
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import retrofit2.Converter
import retrofit2.Retrofit
object SearchConverter : Converter<ResponseBody, List<LobstersPost>> {
override fun convert(value: ResponseBody): List<LobstersPost> {
return value.byteStream().use { stream ->
Jsoup.parse(stream, "UTF-8", LobstersApi.BASE_URL)
.select("div.story_liner.h-entry")
.map(::parsePost)
}
return Ksoup.parse(value.string(), baseUri = LobstersApi.BASE_URL)
.select("div.story_liner.h-entry")
.map(::parsePost)
}
private fun parsePost(elem: Element): LobstersPost {
@@ -33,7 +31,8 @@ object SearchConverter : Converter<ResponseBody, List<LobstersPost>> {
val url = titleElement.attr("href")
val tags = elem.select("span.tags > a").map(Element::text)
val (commentCount, commentsUrl) = getCommentsData(elem.select("span.comments_label"))
val submitter = elem.select("div.byline > a.u-author").text()
val submitter =
elem.select("div.byline > a[href^=/~]:not([tabindex]):not([aria-hidden=true])").text()
val userIsAuthor =
(elem.select("div.byline > span").first()?.text() ?: "").contains(
"authored",
@@ -0,0 +1,29 @@
/*
* 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.api.converters
import java.lang.reflect.Type
import kotlin.Unit
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
object UnitConverter : Converter<ResponseBody, Unit> {
override fun convert(value: ResponseBody) {
value.close()
}
object Factory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
return if (type == Unit::class.java) UnitConverter else null
}
}
}
@@ -6,12 +6,17 @@
*/
package dev.msfjarvis.claw.api.injection
import com.fleeksoft.ksoup.Ksoup
import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory
import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.api.AuthenticatedLobstersApi
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.LobstersSearchApi
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
import dev.msfjarvis.claw.api.converters.ReplyFormConverter
import dev.msfjarvis.claw.api.converters.SearchConverter
import dev.msfjarvis.claw.api.converters.UnitConverter
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
@@ -21,10 +26,12 @@ import dev.zacsweers.metro.Named
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.Qualifier
import dev.zacsweers.metro.SingleIn
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.CallAdapter
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.create
/**
@@ -75,6 +82,11 @@ object RetrofitModule {
@SingleIn(AppScope::class)
fun provideApi(retrofit: Retrofit): LobstersApi = retrofit.create()
@Provides
@SingleIn(AppScope::class)
fun provideAuthenticatedApi(api: LobstersApi): AuthenticatedLobstersApi =
AuthenticatedLobstersApi(api)
@Provides
@SingleIn(AppScope::class)
fun provideSearchApi(@SearchApi retrofit: Retrofit): LobstersSearchApi = retrofit.create()
@@ -84,16 +96,37 @@ object RetrofitModule {
@IntoMap
fun provideApiResultConverter(): Converter.Factory = ApiResultConverterFactory
@Provides
@IntKey(1)
@IntoMap
fun provideCSRFTokenConverter(): Converter.Factory = CSRFTokenConverter.Factory
@Provides
@IntKey(2)
@IntoMap
fun provideReplyFormConverter(): Converter.Factory = ReplyFormConverter.Factory
@Provides
@IntKey(3)
@IntoMap
fun provideUnitConverter(): Converter.Factory = UnitConverter.Factory
@Provides
@IntKey(4)
@IntoMap
fun provideKspoonConverter(): Converter.Factory =
Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
.toFormat()
.asConverterFactory("text/html".toMediaType())
@Provides
@IntKey(0)
@IntoMap
fun provideApiResultCallAdapter(): CallAdapter.Factory = ApiResultCallAdapterFactory
@Provides
@IntKey(Int.MAX_VALUE)
@IntoMap
fun provideCSRFTokenConverter(): Converter.Factory = CSRFTokenConverter.Factory
@Provides
@SearchApi
fun provideConverters(): List<Converter.Factory> =
@@ -6,14 +6,15 @@
*/
package dev.msfjarvis.claw.api
import com.fleeksoft.ksoup.Ksoup
import com.google.common.truth.Truth.assertThat
import com.slack.eithernet.ApiResult.Success
import com.slack.eithernet.test.newEitherNetController
import dev.msfjarvis.claw.model.LobstersPost
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.assertIs
import kotlin.time.Instant
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
@@ -25,23 +26,130 @@ class ApiTest {
@Test
fun `api gets correct number of items`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<List<LobstersPost>>>(posts)
assertThat(posts.value).hasSize(25)
assertIs<Success<PostsPage>>(posts)
assertThat(posts.value.posts).hasSize(25)
}
@Test
fun `posts with no urls`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<List<LobstersPost>>>(posts)
val commentsOnlyPosts = posts.value.asSequence().filter { it.url.isEmpty() }.toSet()
assertIs<Success<PostsPage>>(posts)
val commentsOnlyPosts = posts.value.posts.asSequence().filter { it.url.isEmpty() }.toSet()
assertThat(commentsOnlyPosts).hasSize(0)
}
@Test
fun `api parses hottest HTML fixture fields`() = runTest {
val posts = api.getHottestPosts(1)
assertIs<Success<PostsPage>>(posts)
val firstPost = posts.value.posts[0]
assertThat(firstPost.shortId).isEqualTo("jp3nva")
assertThat(firstPost.title).isEqualTo("You probably don't need Yocto, and that's fine")
assertThat(firstPost.submitter).isEqualTo("rw-rw-rw-")
assertThat(firstPost.commentCount).isEqualTo(9)
assertThat(firstPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/jp3nva/you_probably_don_t_need_yocto_s_fine")
assertThat(firstPost.tags).containsExactly("linux")
assertThat(firstPost.userIsAuthor).isTrue()
assertThat(firstPost.createdAt).isEqualTo("2026-05-29T09:08:12Z")
Instant.parse(firstPost.createdAt)
val secondPost = posts.value.posts[1]
assertThat(secondPost.shortId).isEqualTo("lc26ar")
assertThat(secondPost.title).isEqualTo("SQLite Does Not Accept Agentic Code")
assertThat(secondPost.submitter).isEqualTo("hoistbypetard")
assertThat(secondPost.commentCount).isEqualTo(15)
assertThat(secondPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/lc26ar/sqlite_does_not_accept_agentic_code")
assertThat(secondPost.tags).containsExactly("vibecoding")
assertThat(secondPost.userIsAuthor).isFalse()
val noCommentsPost = posts.value.posts.first { it.shortId == "1fkt8w" }
assertThat(noCommentsPost.title).isEqualTo("Patching my guitar amp's firmware")
assertThat(noCommentsPost.submitter).isEqualTo("mcf")
assertThat(noCommentsPost.commentCount).isEqualTo(0)
assertThat(noCommentsPost.commentsUrl)
.isEqualTo("https://lobste.rs/s/1fkt8w/patching_my_guitar_amp_s_firmware")
assertThat(noCommentsPost.tags).containsExactly("hardware", "reversing")
assertThat(noCommentsPost.userIsAuthor).isTrue()
}
@Test
fun `api gets newest posts`() = runTest {
val posts = api.getNewestPosts(1)
assertIs<Success<PostsPage>>(posts)
assertThat(posts.value.posts).hasSize(25)
}
@Test
fun `post details with comments`() = runTest {
val postDetails = api.getPostDetails("tdfoqh")
assertIs<Success<LobstersPostDetails>>(postDetails)
assertThat(postDetails.value.comments).hasSize(10)
val comments = postDetails.value.comments
assertThat(comments).hasSize(10)
assertThat(comments.first().user).isEqualTo("dpercy")
assertThat(comments.first { it.shortId == "pcvbcd" }.score).isEqualTo(2)
assertThat(comments.first().comment).contains("Maybe take the max, instead of the sum?")
assertThat(comments.first { it.shortId == "pcvbcd" }.parentComment).isEqualTo("m3wyu5")
assertThat(comments.first { it.shortId == "lqqn3a" }.parentComment).isEqualTo("owddle")
}
@Test
fun `comments without visible upvoter count have one point from the author`() {
val kspoon = Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
val postDetails =
kspoon.parse<LobstersPostDetails>(
"""
<ol class="stories">
<li class="story" data-shortid="story1">
<span class="link h-cite"><a href="/s/story1/test">Test story</a></span>
<div class="byline">
<a class="u-author" href="/~/submitter">submitter</a>
<time data-at-unix="1710000000"></time>
</div>
</li>
</ol>
<ol class="comments">
<li class="comments_subtree">
<div class="comment" data-shortid="abc123">
<div class="voters"></div>
<div class="details">
<div class="byline">
<a href="/~/author">author</a>
<a href="/c/abc123"><time data-at-unix="1710000000"></time></a>
</div>
<div class="comment_text"><p>Hello</p></div>
</div>
</div>
</li>
</ol>
"""
.trimIndent()
)
assertThat(postDetails.comments.single().score).isEqualTo(1)
}
@Test
fun `edited comments expose a single timestamp and edited state`() = runTest {
val postDetails = api.getPostDetails("tdfoqh")
assertIs<Success<LobstersPostDetails>>(postDetails)
val editedComment = postDetails.value.comments.first { it.shortId == "pcvbcd" }
assertThat(editedComment.edited).isTrue()
assertThat(editedComment.timestamp.epochSeconds).isEqualTo(1658588955)
}
@Test
fun `post details preserves upvoted comments`() = runTest {
val postDetails = wrapper.upvotedPostDetails
assertThat(postDetails.comments.filter { it.isUpvoted }.map { it.shortId })
.containsExactly("ncdsfc")
}
@Test
@@ -57,16 +165,27 @@ class ApiTest {
assertIs<Success<CSRFToken>>(token)
assertThat(token.value.value)
.isEqualTo(
"RPe1m3JaXAq0ti7t15hNixpLvmgJ793Ti6y4fOpxtfenyXQK3WU19m7MJb8RGoIf-tC3C9Ilwb8pv0WJUWLbNA"
"dvJ8r_CkOImcHQ5ZLUWlJeQVoPEPQ3rK85DNgiZJcehafqwYP8jESW8AhMf0uQGLqqLbsarYiISCghnDaUd6wA"
)
}
@Test
fun `retrieve tags`() = runTest {
val tags = api.getTags()
assertIs<Success<List<Tag>>>(tags)
assertThat(tags.value).isNotEmpty()
assertThat(tags.value.first().tag).isEqualTo("ruby")
assertThat(tags.value.first().description).isEqualTo("Ruby programming")
assertIs<Success<TagsPage>>(tags)
assertThat(tags.value.tags).isNotEmpty()
val rubyTag = tags.value.tags.first { it.tag == "ruby" }
assertThat(rubyTag.description).isEqualTo("Ruby programming")
assertThat(rubyTag.privileged).isFalse()
assertThat(rubyTag.active).isTrue()
assertThat(rubyTag.category).isEmpty()
assertThat(rubyTag.isMedia).isFalse()
assertThat(rubyTag.hotnessMod).isEqualTo(0.0)
val newsTag = tags.value.tags.first { it.tag == "news" }
assertThat(newsTag.active).isFalse()
val videoTag = tags.value.tags.first { it.tag == "video" }
assertThat(videoTag.isMedia).isTrue()
}
}
@@ -6,39 +6,39 @@
*/
package dev.msfjarvis.claw.api
import com.fleeksoft.ksoup.Ksoup
import com.slack.eithernet.ApiResult.Companion.success
import com.slack.eithernet.test.EitherNetController
import com.slack.eithernet.test.enqueue
import dev.burnoo.kspoon.Kspoon
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.Tag
import dev.msfjarvis.claw.model.User
import dev.msfjarvis.claw.util.TestUtils.getResource
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
@OptIn(ExperimentalSerializationApi::class)
class ApiWrapper(controller: EitherNetController<LobstersApi>) {
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
private val kspoon = Kspoon {
parse = { html -> Ksoup.parse(html, baseUri = LobstersApi.BASE_URL) }
coerceInputValues = true
}
private val hottest: List<LobstersPost> = json.decodeFromString(getResource("hottest.json"))
private val postsPage: PostsPage = kspoon.parse<PostsPage>(getResource("hottest_page.html"))
private val postDetails: LobstersPostDetails =
json.decodeFromString(getResource("post_details_tdfoqh.json"))
private val user: User = json.decodeFromString(getResource("msfjarvis.json"))
private val tags: List<Tag> = json.decodeFromString(getResource("tags.json"))
kspoon.parse(getResource("post_details_tdfoqh.html"))
val upvotedPostDetails: LobstersPostDetails =
kspoon.parse(getResource("post_details_upvoted.html"))
private val user: User = kspoon.parse(getResource("msfjarvis.html"))
private val tags: TagsPage = kspoon.parse(getResource("tags.html"))
val api = controller.api
val authenticatedApi = AuthenticatedLobstersApi(api)
init {
controller.enqueue(LobstersApi::getHottestPosts) { success(hottest) }
controller.enqueue(LobstersApi::getHottestPosts) { success(hottest) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getHottestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getNewestPosts) { success(postsPage) }
controller.enqueue(LobstersApi::getPostDetails) { success(postDetails) }
controller.enqueue(LobstersApi::getUser) { success(user) }
controller.enqueue(LobstersApi::getTags) { success(tags) }
@@ -49,5 +49,36 @@ class ApiWrapper(controller: EitherNetController<LobstersApi>) {
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::getCSRFToken) {
success(
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::upvoteComment) { success(Unit) }
controller.enqueue(LobstersApi::unvoteComment) { success(Unit) }
controller.enqueue(LobstersApi::getReplyForm) {
success(
dev.msfjarvis.claw.api.converters.ReplyFormConverter.convert(
getResource("reply_form.html").toResponseBody("text/html".toMediaType())
)
)
}
controller.enqueue(LobstersApi::postReply) { success(Unit) }
}
}
@@ -0,0 +1,108 @@
/*
* 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.api
import com.google.common.truth.Truth.assertThat
import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.claw.model.User
import kotlinx.coroutines.test.runTest
import okhttp3.MultipartBody
import org.junit.jupiter.api.Test
class AuthenticatedLobstersApiHeaderTest {
@Test
fun `reply submission matches browser xhr headers`() = runTest {
val api = RecordingLobstersApi()
val authenticatedApi = AuthenticatedLobstersApi(api)
authenticatedApi.reply("kkiqui", "2tskyy", "hello")
assertThat(api.replyFormRequestedWith).isEqualTo("XMLHttpRequest")
assertThat(api.replyFormReferer).isEqualTo("https://lobste.rs/s/2tskyy")
assertThat(api.postReplyRequestedWith).isEqualTo("XMLHttpRequest, XMLHttpRequest")
assertThat(api.postReplyReferer).isEqualTo("https://lobste.rs/s/2tskyy")
assertThat(api.postReplyOrigin).isEqualTo(LobstersApi.BASE_URL)
assertThat(api.postReplyAccept).isEqualTo("*/*")
}
}
private class RecordingLobstersApi : LobstersApi {
var replyFormRequestedWith: String? = null
var replyFormReferer: String? = null
var postReplyRequestedWith: String? = null
var postReplyReferer: String? = null
var postReplyOrigin: String? = null
var postReplyAccept: String? = null
override suspend fun getHottestPosts(page: Int): ApiResult<PostsPage, Unit> = error("unused")
override suspend fun getNewestPosts(page: Int): ApiResult<PostsPage, Unit> = error("unused")
override suspend fun getPostDetails(postId: String): ApiResult<LobstersPostDetails, Unit> =
error("unused")
override suspend fun getUser(username: String): ApiResult<User, Unit> = error("unused")
override suspend fun getCSRFToken(): ApiResult<CSRFToken, Unit> =
ApiResult.success(CSRFToken("csrf-token"))
override suspend fun getTags(): ApiResult<TagsPage, Unit> = error("unused")
override suspend fun upvoteComment(
commentId: String,
csrfToken: String,
requestedWith: String,
reason: MultipartBody.Part,
): ApiResult<Unit, Unit> = error("unused")
override suspend fun unvoteComment(
commentId: String,
csrfToken: String,
requestedWith: String,
reason: MultipartBody.Part,
): ApiResult<Unit, Unit> = error("unused")
override suspend fun getReplyForm(
commentId: String,
csrfToken: String,
requestedWith: String,
referer: String,
): ApiResult<ReplyForm, Unit> {
replyFormRequestedWith = requestedWith
replyFormReferer = referer
return ApiResult.success(
ReplyForm(
authenticityToken = "auth-token",
storyId = "2tskyy",
method = "post",
parentCommentShortId = commentId,
)
)
}
override suspend fun postReply(
csrfToken: String,
requestedWith: String,
referer: String,
origin: String,
accept: String,
authenticityToken: MultipartBody.Part,
storyId: MultipartBody.Part,
method: MultipartBody.Part,
parentCommentShortId: MultipartBody.Part,
comment: MultipartBody.Part,
commit: MultipartBody.Part,
): ApiResult<Unit, Unit> {
postReplyRequestedWith = requestedWith
postReplyReferer = referer
postReplyOrigin = origin
postReplyAccept = accept
return ApiResult.success(Unit)
}
}
@@ -0,0 +1,39 @@
/*
* 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.api
import com.slack.eithernet.ApiResult.Success
import com.slack.eithernet.test.newEitherNetController
import dev.msfjarvis.claw.util.TestUtils.assertIs
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class AuthenticatedLobstersApiTest {
private val wrapper = ApiWrapper(newEitherNetController())
private val authenticatedApi = wrapper.authenticatedApi
@Test
fun `upvote comment succeeds`() = runTest {
val result = authenticatedApi.upvoteComment("0unzyg")
assertIs<Success<Unit>>(result)
}
@Test
fun `unvote comment succeeds`() = runTest {
val result = authenticatedApi.unvoteComment("0unzyg")
assertIs<Success<Unit>>(result)
}
@Test
fun `reply succeeds`() = runTest {
val result = authenticatedApi.reply("edtrox", "znlkib", "Thanks for the details!")
assertIs<Success<Unit>>(result)
}
}
@@ -31,27 +31,26 @@ class SearchApiTest {
private val results =
listOf(
LobstersPost(
shortId = "vvky3g",
shortId = "kgem4b",
createdAt = "",
title = "2024 State of Rust Survey Results",
url = "https://blog.rust-lang.org/2025/02/13/2024-State-Of-Rust-Survey-results.html",
title = "The social contract of writing",
url = "https://jola.dev/posts/the-social-contract-of-writing",
description = "",
commentCount = 10,
commentsUrl = "https://lobste.rs/s/vvky3g/2024_state_rust_survey_results",
submitter = "obsoleszenz",
userIsAuthor = false,
tags = listOf("rust"),
commentCount = 38,
commentsUrl = "https://lobste.rs/s/kgem4b/social_contract_writing",
submitter = "joladev",
userIsAuthor = true,
tags = listOf("philosophy", "vibecoding"),
),
LobstersPost(
shortId = "wjoric",
shortId = "gydtkf",
createdAt = "",
title = "New hack uses prompt injection to corrupt Geminis long-term memory",
url =
"https://arstechnica.com/security/2025/02/new-hack-uses-prompt-injection-to-corrupt-geminis-long-term-memory/",
title = "AI Resist List",
url = "https://airesistlist.org/",
description = "",
commentCount = 0,
commentsUrl = "https://lobste.rs/s/wjoric/new_hack_uses_prompt_injection_corrupt",
submitter = "laktak",
commentsUrl = "https://lobste.rs/s/gydtkf/ai_resist_list",
submitter = "chobeat",
userIsAuthor = false,
tags = listOf("ai"),
),
@@ -0,0 +1,38 @@
/*
* 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.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class CSRFTokenConverterTest {
@Test
fun `converter extracts CSRF token`() {
val token =
CSRFTokenConverter.convert(
getResource("csrf_page.html").toResponseBody("text/html".toMediaType())
)
assertThat(token.value)
.isEqualTo(
"dvJ8r_CkOImcHQ5ZLUWlJeQVoPEPQ3rK85DNgiZJcehafqwYP8jESW8AhMf0uQGLqqLbsarYiISCghnDaUd6wA"
)
}
@Test
fun `converter returns empty token when CSRF token is absent`() {
val token =
CSRFTokenConverter.convert(
"<html><head></head><body></body></html>".toResponseBody("text/html".toMediaType())
)
assertThat(token.value).isEmpty()
}
}
@@ -0,0 +1,41 @@
/*
* 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.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class ReplyFormConverterTest {
@Test
fun `converter extracts reply form hidden fields`() {
val form =
ReplyFormConverter.convert(
getResource("reply_form.html").toResponseBody("text/html".toMediaType())
)
assertThat(form.authenticityToken)
.isEqualTo(
"AI0414bnzi152-mE0JTWEtwq5B0ZhALBW1W7rGiG5zR-sFaJjWzdARXFM7w_DbPQqWNjzh9bufWZbXG39v5T6g"
)
assertThat(form.storyId).isEqualTo("znlkib")
assertThat(form.method).isEqualTo("post")
assertThat(form.parentCommentShortId).isEqualTo("edtrox")
}
@Test
fun `converter returns empty strings when reply form fields are absent`() {
val form = ReplyFormConverter.convert("<form></form>".toResponseBody("text/html".toMediaType()))
assertThat(form.authenticityToken).isEmpty()
assertThat(form.storyId).isEmpty()
assertThat(form.method).isEmpty()
assertThat(form.parentCommentShortId).isEmpty()
}
}
@@ -0,0 +1,54 @@
/*
* 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.api.converters
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.util.TestUtils.getResource
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
class SearchConverterTest {
@Test
fun `converter parses search result HTML`() {
val posts =
SearchConverter.convert(
getResource("search_chatgpt_page.html").toResponseBody("text/html".toMediaType())
)
assertThat(posts).hasSize(20)
assertThat(posts)
.containsAtLeast(
LobstersPost(
shortId = "kgem4b",
createdAt = "",
title = "The social contract of writing",
url = "https://jola.dev/posts/the-social-contract-of-writing",
description = "",
commentCount = 38,
commentsUrl = "https://lobste.rs/s/kgem4b/social_contract_writing",
submitter = "joladev",
userIsAuthor = true,
tags = listOf("philosophy", "vibecoding"),
),
LobstersPost(
shortId = "gydtkf",
createdAt = "",
title = "AI Resist List",
url = "https://airesistlist.org/",
description = "",
commentCount = 0,
commentsUrl = "https://lobste.rs/s/gydtkf/ai_resist_list",
submitter = "chobeat",
userIsAuthor = false,
tags = listOf("ai"),
),
)
.inOrder()
}
}
+1430 -4
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="user">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="msfjarvis">
<meta property="og:description" content="Android and Kotlin developer, currently working for Cloudflare
openpgp4fpr:8F87050B0F9CB84115157399B7843F823355E9B9
">
<meta property="og:image" content="/avatars/msfjarvis-100.png">
<link rel="shortcut icon" href="/favicon.ico">
<title>msfjarvis | Lobsters</title>
<link rel="stylesheet" href="/assets/application-8d029e37.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="qzhV0a2izz-6n516jIrQyKzp06y_9Fb33KCrZtCTdx6_KPhm894c4F-8c5CTUkxlC_xqaR2nsqf416xcza-daw">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="/3z7A9BvciTcEaW9gzJQnQ==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1780070762'>
<header id="nav">
<a id="logo" style="background-color: #6e0000;" href="/" title="Lobsters (Current traffic: 17%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<h1 class="">msfjarvis</h1>
<section class="profile">
<div id="gravatar">
<img srcset="/avatars/msfjarvis-100.png 1x, /avatars/msfjarvis-200.png 2x" class="avatar" alt="msfjarvis avatar" loading="lazy" decoding="async" src="/avatars/msfjarvis-100.png" width="100" height="100">
</div>
<div class="labelled_grid">
<label>Status</label>
<span>
Active
user
</span>
<label>Joined</label>
<span>
<time title="2020-04-24 11:41:56" datetime="2020-04-24 11:41:56" data-at-unix="1587746516">6 years ago</time>
by <a href="/users#msfjarvis">invitation</a> from
<a href="/~Amolith">Amolith</a>
</span>
<label>Karma</label>
<span>
2817
</span>
<label>Stories Submitted</label>
<span>
<a href="/~msfjarvis/stories">38</a>, most commonly tagged <a class="tag tag_rust" title="Rust programming" href="/t/rust">rust</a>
</span>
<label>Comments Posted</label>
<span><a href="/~msfjarvis/threads">258</a></span>
<label>Homepage</label>
<span>
<a href="https://msfjarvis.dev"
rel="me ugc">https://msfjarvis.dev</a>
</span>
<label>GitHub</label>
<span>
<a href="https://github.com/msfjarvis"
rel="me ugc">https://github.com/msfjarvis</a>
</span>
<label>Mastodon</label>
<span>
<a href="https://androiddev.social/@msfjarvis"
rel="me ugc">@msfjarvis@androiddev.social
</a>
</span>
<label>About</label>
<div class="shorten_first_p">
<p>Android and Kotlin developer, currently working for <a href="https://cloudflare.com/" rel="ugc">Cloudflare</a></p>
<p>openpgp4fpr:8F87050B0F9CB84115157399B7843F823355E9B9</p>
</div>
</div>
</section>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
{"username":"msfjarvis","created_at":"2020-04-24T11:41:56.000-05:00","is_admin":false,"about":"Android and Kotlin developer, currently working for [Dyte](https://dyte.io/)","is_moderator":false,"karma":1343,"avatar_url":"/avatars/msfjarvis-100.png","invited_by_user":"Amolith","github_username":"msfjarvis"}
+813
View File
@@ -0,0 +1,813 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="Ranking comments by sum of replies&#39; scores">
<meta property="og:description" content="10 comments">
<meta property="og:image" content="https://lobste.rs/story_image/tdfoqh.png">
<meta property="article:author" content="https://lobste.rs/~gioele">
<link rel="shortcut icon" href="/favicon.ico">
<link href="https://lobste.rs/s/tdfoqh/ranking_comments_by_sum_replies_scores" rev="canonical" rel="self alternate shorter shorturl shortlink">
<title>Ranking comments by sum of replies&#39; scores | Lobsters</title>
<link rel="stylesheet" href="/assets/application-8d029e37.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="4i6TIGJDGnIZG7YaRMHPyYYg-LoCzf2nZznREoRDfQv26Eln3eTfll3z-c6sypfCq2kn5OtRI3_cD3ZCNQOe4Q">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="0Tcy0yahOOA5lchr3moUHw==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1780070763'>
<header id="nav">
<a id="logo" style="background-color: #6e0000;" href="/" title="Lobsters (Current traffic: 17%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<ol class="stories">
<li id="story_tdfoqh" data-shortid="tdfoqh" class="story">
<div class="story_liner h-entry">
<div class="voters">
<a class="upvoter" href="/login">18</a>
</div>
<div class="details" id="header_story_01g8ke0w20enk85qfby8bp0cgy">
<span role="heading" aria-level="1" class="link h-cite u-repost-of">
<a href="/s/tdfoqh/ranking_comments_by_sum_replies_scores" rel="ugc noreferrer" class="u-url">Ranking comments by sum of replies&#39; scores</a>
</span>
<a class="description_present" title="There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.
These threads are not shown at the top of the story&#39;s comments page because their &quot;root comment&quot; has a low score.
An example, as of 2022-07-22 17:00 UTC, is &lt;https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt&gt;:
* the root comment has 1 point,
* the first reply has 12 points,
* and, in total, 18 points have been accumulated by all the r..." href="/s/tdfoqh/ranking_comments_by_sum_replies_scores">&#x2636;</a>
<span class="tags">
<a class="tag tag_meta" title="Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters" href="/t/meta">meta</a>
</span>
<div class="byline">
<span>authored by</span>
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" href="/~gioele">gioele</a>
<time title="2022-07-22 12:06:59" datetime="2022-07-22 12:06:59" data-at-unix="1658509619">3 years ago</time>
<span class="comments_label">
<span aria-hidden="true"> | </span>
<a role="heading" aria-level="2" href="#comments-tdfoqh">
10
comments
</a>
</span>
</div>
</div>
</div>
</li>
</ol>
<div class="story_content">
<div class="story_text">
<p>There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.</p>
<p>These threads are not shown at the top of the story's comments page because their "root comment" has a low score.</p>
<p>An example, as of 2022-07-22 17:00 UTC, is <a href="https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt" rel="ugc">https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt</a>:</p>
<ul>
<li>the root comment has 1 point,</li>
<li>the first reply has 12 points,</li>
<li>and, in total, 18 points have been accumulated by all the replies with score &gt; 1 point.</li>
</ul>
<p>Because the root comment has only 1 point, it remains at the bottom of the page, bringing down with itself also the good comment with 12 points and all the other upvoted comments.</p>
<p>Could an alternative ranking system be put in place, where the threads and subthreads are not sorted by the score of their root comment, but by the <em>sum of all the scores of their children</em>?</p>
<p>(Maybe taking into account only children with <code>&gt;1</code> point, to avoid giving importance to superficial back-and-forth discussions.)</p>
</div>
</div>
<ol class="comments comments1">
<li class="comments_subtree" id="comments-tdfoqh">
<ol class="comments" id="story_comments">
<li class="comments_subtree">
<input id="comment_folder_ncdsfc"
class="comment_folder_button" type="checkbox"
>
<div id="c_ncdsfc"
data-shortid="ncdsfc"
class="comment
">
<div class="voters">
<label for="comment_folder_ncdsfc" class="comment_folder"></label>
<a class="upvoter" title="18" href="/login">18</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ncdsfc"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~dpercy"><img srcset="/avatars/dpercy-16.png 1x, /avatars/dpercy-32.png 2x" class="avatar" alt="dpercy avatar" loading="lazy" decoding="async" src="/avatars/dpercy-16.png" width="16" height="16"></a>
<a href="/~dpercy">dpercy</a>
<a href="/c/ncdsfc"><time title="2022-07-22 14:04:36" datetime="2022-07-22 14:04:36" data-at-unix="1658516676">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>to avoid giving importance to superficial back-and-forth discussions</p>
</blockquote>
<p>Maybe take the max, instead of the sum? That way it's still comparing the score of individual comments. A highly-upvoted child would rank higher than a less-upvoted toplevel comment, but long threads wouldn't get any bonus.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_m3wyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_m3wyu5"
data-shortid="m3wyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_m3wyu5" class="comment_folder"></label>
<a class="upvoter" title="8" href="/login">8</a>
</div>
<div class="details">
<div class="byline">
<a name="c_m3wyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~friendlysock"><img srcset="/avatars/friendlysock-16.png 1x, /avatars/friendlysock-32.png 2x" class="avatar" alt="friendlysock avatar" loading="lazy" decoding="async" src="/avatars/friendlysock-16.png" width="16" height="16"></a>
<a class="inactive_user" aria-label="friendlysock - Inactive user" href="/~friendlysock">friendlysock</a>
<a href="/c/m3wyu5"><time title="2022-07-23 01:04:43" datetime="2022-07-23 01:04:43" data-at-unix="1658556283">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>For better or worse, the deeper you go into threads the less likely it is that the discussion is still about the original submission. Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_pcvbcd"
class="comment_folder_button" type="checkbox"
>
<div id="c_pcvbcd"
data-shortid="pcvbcd"
class="comment
">
<div class="voters">
<label for="comment_folder_pcvbcd" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_pcvbcd"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author user_is_submitter" aria-label="gioele - Submitter" href="/~gioele">gioele</a>
edited
<a href="/c/pcvbcd"><time title="2022-07-23 10:09:15" datetime="2022-07-23 10:09:15" data-at-unix="1658588955">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</blockquote>
<p>Such off-topic discussions that stray away from the main topic do exist, are a nuisance, and, surely, they should not be rewarded with more attention. However, such sub-threads exists as a long string of short comments with no or very few upvotes. (I wonder if the data confirms this or it is just my impression.)</p>
<p>For this reason I believe that a) not counting in comments with score==1 or b) <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a>'s max variant would avoid rewarding such discussions.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_9vyyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_9vyyu5"
data-shortid="9vyyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_9vyyu5" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_9vyyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/9vyyu5"><time title="2022-07-22 13:39:20" datetime="2022-07-22 13:39:20" data-at-unix="1658515160">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
<p>There are many instances where the two are correlated, and some important instances where they are not.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_owddle"
class="comment_folder_button" type="checkbox"
>
<div id="c_owddle"
data-shortid="owddle"
class="comment
">
<div class="voters">
<label for="comment_folder_owddle" class="comment_folder"></label>
<a class="upvoter" title="7" href="/login">7</a>
</div>
<div class="details">
<div class="byline">
<a name="c_owddle"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author user_is_submitter" aria-label="gioele - Submitter" href="/~gioele">gioele</a>
<a href="/c/owddle"><time title="2022-07-22 13:57:19" datetime="2022-07-22 13:57:19" data-at-unix="1658516239">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
</blockquote>
<p>I fully agree: upvotes are a metric of popularity and not of quality. But this proposal does not assume that.</p>
<p>Regardless of what upvotes represent, currently threads are sorted in descending order of (upvotes, time). All that this proposal suggests, is to change that to (sum(upvotes of root-and-children), time).</p>
<p>The only assumption here is: if ranking a thread by upvotes of its root comment is considered good (and at the moment it is), then ranking by the sum of the upvotes of the whole thread is better.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_lqqn3a"
class="comment_folder_button" type="checkbox"
>
<div id="c_lqqn3a"
data-shortid="lqqn3a"
class="comment
">
<div class="voters">
<label for="comment_folder_lqqn3a" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_lqqn3a"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/lqqn3a"><time title="2022-07-22 14:28:27" datetime="2022-07-22 14:28:27" data-at-unix="1658518107">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Tangential to your effort here, on a personal note, if I find a discussion interesting I read all the posts regardless of ranking. On the discussions I find worthwhile I find that post quality is unrelated to upvotes.</p>
<p>On some discussions I find the top voted comment has devolved into a long tail of niche discussion, often acrimonious, which is not as useful as later posts.</p>
<p>Worst to pick out of the noise are replies to replies which are great but buried in not so great comments.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_7b0jgw"
class="comment_folder_button" type="checkbox"
>
<div id="c_7b0jgw"
data-shortid="7b0jgw"
class="comment
">
<div class="voters">
<label for="comment_folder_7b0jgw" class="comment_folder"></label>
<a class="upvoter" title="4" href="/login">4</a>
</div>
<div class="details">
<div class="byline">
<a name="c_7b0jgw"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FeepingCreature"><img srcset="/avatars/FeepingCreature-16.png 1x, /avatars/FeepingCreature-32.png 2x" class="avatar" alt="FeepingCreature avatar" loading="lazy" decoding="async" src="/avatars/FeepingCreature-16.png" width="16" height="16"></a>
<a href="/~FeepingCreature">FeepingCreature</a>
<a href="/c/7b0jgw"><time title="2022-07-23 11:26:03" datetime="2022-07-23 11:26:03" data-at-unix="1658593563">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Ranking by max or sum upvotes optimizes for photogenic smackdowns of bad opinions.</p>
<p>Maybe disaggregate "agreement upvotes" and "contribution upvotes"?</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_ei3nck"
class="comment_folder_button" type="checkbox"
>
<div id="c_ei3nck"
data-shortid="ei3nck"
class="comment
">
<div class="voters">
<label for="comment_folder_ei3nck" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ei3nck"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FRIGN"><img srcset="/avatars/FRIGN-16.png 1x, /avatars/FRIGN-32.png 2x" class="avatar" alt="FRIGN avatar" loading="lazy" decoding="async" src="/avatars/FRIGN-16.png" width="16" height="16"></a>
<a href="/~FRIGN">FRIGN</a>
edited
<a href="/c/ei3nck"><time title="2022-07-23 10:48:49" datetime="2022-07-23 10:48:49" data-at-unix="1658591329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I feel kind of honored to be taken as an example, so please let me share my personal thoughts (TL;DR below):</p>
<p>It is impossible to find <em>the</em> definite ranking system, but let me elaborate by using 3 extremes to see where the current and proposed systems fail:</p>
<ol>
<li>
<em>highly controversial root comment</em> with score 0 (100 upvotes, 100 downvotes), logically controversial responses with median 0,</li>
<li>
<em>root comment preaching to the choir</em> with logically high score and usually high-score responses,</li>
<li>
<em>low-score root comment with high-score responses</em> (like the presented case)</li>
<li>
<em>OC high-value root comment</em> with high score and high-value responses</li>
</ol>
<p>It all depends on personal preference though, but I personally would like more controversial topics to be on top and less controversial ones to be secondary. The reason for that is that you otherwise might end up with echo-chambers, given users are encouraged to preach to the choir for a high karma score. As a tangent, I would prefer two karmas for each user: A "paragon-karma" and "renegade-karma" whose sum would be an "influence" score (in the end up- or downvotes both reflect a certain influence) and whose difference would be a "non-controversiality-score" (a low difference reflects controversiality, a high score the opposite). All in all, maybe we should move away complete from the miriad of downvote-options on lobsters and simply have an "agree" and "disagree". If something is spam, you can report it, if something is incorrect, you can write a response rectifying it (which would then be subject to a vote of agreement and disagreement, depending on how well you state your case).</p>
<p>The proposal to add replies into the weight would not change the ranking of controversial topics (1), but even moreso weigh comments preaching to the choir (2) because everyone replying would aim to also get some karma. It would solve the presented case (3) though. Case (4) would also be favoured.</p>
<p>The proposal by <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a> to the take the max would not change (1)'s ranking, probably not affect (2) but help (3) as well. (4) is also positively affected.</p>
<p>TL;DR, here's my proposal so more highly-controversial topics (those that are interesting) are more favoured: Consider root comment and replies as equals and only take <em>influence</em> (sum of up- and downvotes) as a score.</p>
<p>This benefits (1), which in the current form end up at the bottom, which makes zero sense. It also benefits (2) and (4), which is a forced compromise, given the score does not really show how "original" a comment is (this is why Reddit probably introduced awards to allow users to weigh very good posts). By taking all replies into account, it also, of course, benefits (3), which makes sense to push up.</p>
<p>Replies with no votes increase the influence of the thread by 1, indeed, but isn't the purpose of the score to show what the hivemind thinks? By scoring them as zero (which was proposed here), you effectively value an elaborated response lower than a simple upvote of the original comment. Instead, why not simply "fold" long subthreads so they don't take up too much space?</p>
<p>As another tangent: By weighing root and children, this might encourage people to respond to "deep" threads instead of writing a new post.</p>
<p>Let's see what <a href="https://lobste.rs/~pushcx" rel="ugc">@pushcx</a> decides in the end. :)</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_4l8mzk"
class="comment_folder_button" type="checkbox"
>
<div id="c_4l8mzk"
data-shortid="4l8mzk"
class="comment
">
<div class="voters">
<label for="comment_folder_4l8mzk" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_4l8mzk"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~feoh"><img srcset="/avatars/feoh-16.png 1x, /avatars/feoh-32.png 2x" class="avatar" alt="feoh avatar" loading="lazy" decoding="async" src="/avatars/feoh-16.png" width="16" height="16"></a>
<a href="/~feoh">feoh</a>
<a href="/c/4l8mzk"><time title="2022-07-22 12:30:20" datetime="2022-07-22 12:30:20" data-at-unix="1658511020">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I really like this idea but have no idea what its implementation would represent in terms of additional load to the server.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_qy0l9k"
class="comment_folder_button" type="checkbox"
>
<div id="c_qy0l9k"
data-shortid="qy0l9k"
class="comment
">
<div class="voters">
<label for="comment_folder_qy0l9k" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_qy0l9k"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~pushcx"><img srcset="/avatars/pushcx-16.png 1x, /avatars/pushcx-32.png 2x" class="avatar" alt="pushcx avatar" loading="lazy" decoding="async" src="/avatars/pushcx-16.png" width="16" height="16"></a>
<a href="/~pushcx">pushcx</a>
<span class="hat hat_sysop" title="Granted 2017-10-15"><span class="crown">Sysop</span></span>
<a href="/c/qy0l9k"><time title="2022-07-22 16:12:09" datetime="2022-07-22 16:12:09" data-at-unix="1658524329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I've moved most of the <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L340" rel="ugc">vote</a> into the db, but <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L219" rel="ugc">not all</a>. If <code>calculated_confidence</code> finished its move into the db almost any algorithm would be faster than the current implementation.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<br>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
File diff suppressed because one or more lines are too long
+803
View File
@@ -0,0 +1,803 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Lobsters">
<meta property="og:title" content="Ranking comments by sum of replies&#39; scores">
<meta property="og:description" content="10 comments">
<meta property="og:image" content="https://lobste.rs/story_image/tdfoqh.png">
<meta property="article:author" content="https://lobste.rs/~gioele">
<link rel="shortcut icon" href="/favicon.ico">
<link href="https://lobste.rs/s/tdfoqh/ranking_comments_by_sum_replies_scores" rev="canonical" rel="self alternate shorter shorturl shortlink">
<title>Ranking comments by sum of replies&#39; scores | Lobsters</title>
<link rel="stylesheet" href="/assets/application-50d965fb.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="I9QGMwqhWWuCkGBSdDF7vV0v16wATa6x6zmxpaCQKS4iehHByjKy34YLK6cPpB626ZL-FaO10jcm3edeXIFrBw">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="owvfCFqNS/eLJHNrSN2nYg==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1778381500'>
<header id="nav">
<a id="logo" style="background-color: #550000;" href="/" title="Lobsters (Current traffic: 3%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<ol class="stories">
<li id="story_tdfoqh" data-shortid="tdfoqh" class="story">
<div class="story_liner h-entry">
<div class="voters">
<a class="upvoter" href="/login">18</a>
</div>
<div class="details" id="header_story_01g8ke0w20enk85qfby8bp0cgy">
<span role="heading" aria-level="1" class="link h-cite u-repost-of">
<a href="/s/tdfoqh/ranking_comments_by_sum_replies_scores" rel="ugc noreferrer" class="u-url">Ranking comments by sum of replies&#39; scores</a>
</span>
<a class="description_present" title="There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.
These threads are not shown at the top of the story&#39;s comments page because their &quot;root comment&quot; has a low score.
An example, as of 2022-07-22 17:00 UTC, is &lt;https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt&gt;:
* the root comment has 1 point,
* the first reply has 12 points,
* and, in total, 18 points have been accumulated by all the r..." href="/s/tdfoqh/ranking_comments_by_sum_replies_scores">&#x2636;</a>
<span class="tags">
<a class="tag tag_meta" title="Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters" href="/t/meta">meta</a>
</span>
<div class="byline">
<span>authored by</span>
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="u-author h-card user_is_author" href="/~gioele">gioele</a>
<time title="2022-07-22 12:06:59" datetime="2022-07-22 12:06:59" data-at-unix="1658509619">3 years ago</time>
<span class="comments_label">
<span aria-hidden="true"> | </span>
<a role="heading" aria-level="2" href="/s/tdfoqh/ranking_comments_by_sum_replies_scores#comments_story_01g8ke0w20enk85qfby8bp0cgy">
10
comments
</a>
</span>
</div>
</div>
</div>
</li>
</ol>
<div class="story_content">
<div class="story_text">
<p>There are often cases where comments with a poor score (say, 1 point), invite high-quality replies that receive many upvotes.</p>
<p>These threads are not shown at the top of the story's comments page because their "root comment" has a low score.</p>
<p>An example, as of 2022-07-22 17:00 UTC, is <a href="https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt" rel="ugc">https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt</a>:</p>
<ul>
<li>the root comment has 1 point,</li>
<li>the first reply has 12 points,</li>
<li>and, in total, 18 points have been accumulated by all the replies with score &gt; 1 point.</li>
</ul>
<p>Because the root comment has only 1 point, it remains at the bottom of the page, bringing down with itself also the good comment with 12 points and all the other upvoted comments.</p>
<p>Could an alternative ranking system be put in place, where the threads and subthreads are not sorted by the score of their root comment, but by the <em>sum of all the scores of their children</em>?</p>
<p>(Maybe taking into account only children with <code>&gt;1</code> point, to avoid giving importance to superficial back-and-forth discussions.)</p>
</div>
</div>
<ol class="comments comments1">
<li class="comments_subtree" id="story_comments">
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_ncdsfc"
class="comment_folder_button" type="checkbox"
>
<div id="c_ncdsfc"
data-shortid="ncdsfc"
class="comment upvoted
">
<div class="voters">
<label for="comment_folder_ncdsfc" class="comment_folder"></label>
<a class="upvoter" title="18" href="/login">18</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ncdsfc"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~dpercy"><img srcset="/avatars/dpercy-16.png 1x, /avatars/dpercy-32.png 2x" class="avatar" alt="dpercy avatar" loading="lazy" decoding="async" src="/avatars/dpercy-16.png" width="16" height="16"></a>
<a href="/~dpercy">dpercy</a>
<a href="/c/ncdsfc"><time title="2022-07-22 14:04:36" datetime="2022-07-22 14:04:36" data-at-unix="1658516676">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>to avoid giving importance to superficial back-and-forth discussions</p>
</blockquote>
<p>Maybe take the max, instead of the sum? That way it's still comparing the score of individual comments. A highly-upvoted child would rank higher than a less-upvoted toplevel comment, but long threads wouldn't get any bonus.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_m3wyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_m3wyu5"
data-shortid="m3wyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_m3wyu5" class="comment_folder"></label>
<a class="upvoter" title="8" href="/login">8</a>
</div>
<div class="details">
<div class="byline">
<a name="c_m3wyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~friendlysock"><img srcset="/avatars/friendlysock-16.png 1x, /avatars/friendlysock-32.png 2x" class="avatar" alt="friendlysock avatar" loading="lazy" decoding="async" src="/avatars/friendlysock-16.png" width="16" height="16"></a>
<a class="inactive_user" aria-label="friendlysock - Inactive user" href="/~friendlysock">friendlysock</a>
<a href="/c/m3wyu5"><time title="2022-07-23 01:04:43" datetime="2022-07-23 01:04:43" data-at-unix="1658556283">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>For better or worse, the deeper you go into threads the less likely it is that the discussion is still about the original submission. Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_pcvbcd"
class="comment_folder_button" type="checkbox"
>
<div id="c_pcvbcd"
data-shortid="pcvbcd"
class="comment
">
<div class="voters">
<label for="comment_folder_pcvbcd" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_pcvbcd"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" aria-label="gioele - Author" href="/~gioele">gioele</a>
edited
<a href="/c/pcvbcd"><time title="2022-07-23 10:09:15" datetime="2022-07-23 10:09:15" data-at-unix="1658588955">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Your proposal, or the max variant presented elsewhere, I fear would unduly reward flamebait and off-topic discussion.</p>
</blockquote>
<p>Such off-topic discussions that stray away from the main topic do exist, are a nuisance, and, surely, they should not be rewarded with more attention. However, such sub-threads exists as a long string of short comments with no or very few upvotes. (I wonder if the data confirms this or it is just my impression.)</p>
<p>For this reason I believe that a) not counting in comments with score==1 or b) <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a>'s max variant would avoid rewarding such discussions.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_9vyyu5"
class="comment_folder_button" type="checkbox"
>
<div id="c_9vyyu5"
data-shortid="9vyyu5"
class="comment
">
<div class="voters">
<label for="comment_folder_9vyyu5" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_9vyyu5"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/9vyyu5"><time title="2022-07-22 13:39:20" datetime="2022-07-22 13:39:20" data-at-unix="1658515160">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
<p>There are many instances where the two are correlated, and some important instances where they are not.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_owddle"
class="comment_folder_button" type="checkbox"
>
<div id="c_owddle"
data-shortid="owddle"
class="comment
">
<div class="voters">
<label for="comment_folder_owddle" class="comment_folder"></label>
<a class="upvoter" title="7" href="/login">7</a>
</div>
<div class="details">
<div class="byline">
<a name="c_owddle"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~gioele"><img srcset="/avatars/gioele-16.png 1x, /avatars/gioele-32.png 2x" class="avatar" alt="gioele avatar" loading="lazy" decoding="async" src="/avatars/gioele-16.png" width="16" height="16"></a>
<a class="user_is_author" aria-label="gioele - Author" href="/~gioele">gioele</a>
<a href="/c/owddle"><time title="2022-07-22 13:57:19" datetime="2022-07-22 13:57:19" data-at-unix="1658516239">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<blockquote>
<p>Just like to point out that the assumption here is that upvotes is a metric of quality. On lobste.rs as in any other social context, upvotes/support is a measure of popularity.</p>
</blockquote>
<p>I fully agree: upvotes are a metric of popularity and not of quality. But this proposal does not assume that.</p>
<p>Regardless of what upvotes represent, currently threads are sorted in descending order of (upvotes, time). All that this proposal suggests, is to change that to (sum(upvotes of root-and-children), time).</p>
<p>The only assumption here is: if ranking a thread by upvotes of its root comment is considered good (and at the moment it is), then ranking by the sum of the upvotes of the whole thread is better.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_lqqn3a"
class="comment_folder_button" type="checkbox"
>
<div id="c_lqqn3a"
data-shortid="lqqn3a"
class="comment
">
<div class="voters">
<label for="comment_folder_lqqn3a" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_lqqn3a"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~kghose"><img srcset="/avatars/kghose-16.png 1x, /avatars/kghose-32.png 2x" class="avatar" alt="kghose avatar" loading="lazy" decoding="async" src="/avatars/kghose-16.png" width="16" height="16"></a>
<a href="/~kghose">kghose</a>
<a href="/c/lqqn3a"><time title="2022-07-22 14:28:27" datetime="2022-07-22 14:28:27" data-at-unix="1658518107">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Tangential to your effort here, on a personal note, if I find a discussion interesting I read all the posts regardless of ranking. On the discussions I find worthwhile I find that post quality is unrelated to upvotes.</p>
<p>On some discussions I find the top voted comment has devolved into a long tail of niche discussion, often acrimonious, which is not as useful as later posts.</p>
<p>Worst to pick out of the noise are replies to replies which are great but buried in not so great comments.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_7b0jgw"
class="comment_folder_button" type="checkbox"
>
<div id="c_7b0jgw"
data-shortid="7b0jgw"
class="comment
">
<div class="voters">
<label for="comment_folder_7b0jgw" class="comment_folder"></label>
<a class="upvoter" title="4" href="/login">4</a>
</div>
<div class="details">
<div class="byline">
<a name="c_7b0jgw"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FeepingCreature"><img srcset="/avatars/FeepingCreature-16.png 1x, /avatars/FeepingCreature-32.png 2x" class="avatar" alt="FeepingCreature avatar" loading="lazy" decoding="async" src="/avatars/FeepingCreature-16.png" width="16" height="16"></a>
<a href="/~FeepingCreature">FeepingCreature</a>
<a href="/c/7b0jgw"><time title="2022-07-23 11:26:03" datetime="2022-07-23 11:26:03" data-at-unix="1658593563">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>Ranking by max or sum upvotes optimizes for photogenic smackdowns of bad opinions.</p>
<p>Maybe disaggregate "agreement upvotes" and "contribution upvotes"?</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_ei3nck"
class="comment_folder_button" type="checkbox"
>
<div id="c_ei3nck"
data-shortid="ei3nck"
class="comment
">
<div class="voters">
<label for="comment_folder_ei3nck" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_ei3nck"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~FRIGN"><img srcset="/avatars/FRIGN-16.png 1x, /avatars/FRIGN-32.png 2x" class="avatar" alt="FRIGN avatar" loading="lazy" decoding="async" src="/avatars/FRIGN-16.png" width="16" height="16"></a>
<a href="/~FRIGN">FRIGN</a>
edited
<a href="/c/ei3nck"><time title="2022-07-23 10:48:49" datetime="2022-07-23 10:48:49" data-at-unix="1658591329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I feel kind of honored to be taken as an example, so please let me share my personal thoughts (TL;DR below):</p>
<p>It is impossible to find <em>the</em> definite ranking system, but let me elaborate by using 3 extremes to see where the current and proposed systems fail:</p>
<ol>
<li>
<em>highly controversial root comment</em> with score 0 (100 upvotes, 100 downvotes), logically controversial responses with median 0,</li>
<li>
<em>root comment preaching to the choir</em> with logically high score and usually high-score responses,</li>
<li>
<em>low-score root comment with high-score responses</em> (like the presented case)</li>
<li>
<em>OC high-value root comment</em> with high score and high-value responses</li>
</ol>
<p>It all depends on personal preference though, but I personally would like more controversial topics to be on top and less controversial ones to be secondary. The reason for that is that you otherwise might end up with echo-chambers, given users are encouraged to preach to the choir for a high karma score. As a tangent, I would prefer two karmas for each user: A "paragon-karma" and "renegade-karma" whose sum would be an "influence" score (in the end up- or downvotes both reflect a certain influence) and whose difference would be a "non-controversiality-score" (a low difference reflects controversiality, a high score the opposite). All in all, maybe we should move away complete from the miriad of downvote-options on lobsters and simply have an "agree" and "disagree". If something is spam, you can report it, if something is incorrect, you can write a response rectifying it (which would then be subject to a vote of agreement and disagreement, depending on how well you state your case).</p>
<p>The proposal to add replies into the weight would not change the ranking of controversial topics (1), but even moreso weigh comments preaching to the choir (2) because everyone replying would aim to also get some karma. It would solve the presented case (3) though. Case (4) would also be favoured.</p>
<p>The proposal by <a href="https://lobste.rs/~dpercy" rel="ugc">@dpercy</a> to the take the max would not change (1)'s ranking, probably not affect (2) but help (3) as well. (4) is also positively affected.</p>
<p>TL;DR, here's my proposal so more highly-controversial topics (those that are interesting) are more favoured: Consider root comment and replies as equals and only take <em>influence</em> (sum of up- and downvotes) as a score.</p>
<p>This benefits (1), which in the current form end up at the bottom, which makes zero sense. It also benefits (2) and (4), which is a forced compromise, given the score does not really show how "original" a comment is (this is why Reddit probably introduced awards to allow users to weigh very good posts). By taking all replies into account, it also, of course, benefits (3), which makes sense to push up.</p>
<p>Replies with no votes increase the influence of the thread by 1, indeed, but isn't the purpose of the score to show what the hivemind thinks? By scoring them as zero (which was proposed here), you effectively value an elaborated response lower than a simple upvote of the original comment. Instead, why not simply "fold" long subthreads so they don't take up too much space?</p>
<p>As another tangent: By weighing root and children, this might encourage people to respond to "deep" threads instead of writing a new post.</p>
<p>Let's see what <a href="https://lobste.rs/~pushcx" rel="ugc">@pushcx</a> decides in the end. :)</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
<li class="comments_subtree">
<input id="comment_folder_4l8mzk"
class="comment_folder_button" type="checkbox"
>
<div id="c_4l8mzk"
data-shortid="4l8mzk"
class="comment
">
<div class="voters">
<label for="comment_folder_4l8mzk" class="comment_folder"></label>
<a class="upvoter" title="2" href="/login">2</a>
</div>
<div class="details">
<div class="byline">
<a name="c_4l8mzk"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~feoh"><img srcset="/avatars/feoh-16.png 1x, /avatars/feoh-32.png 2x" class="avatar" alt="feoh avatar" loading="lazy" decoding="async" src="/avatars/feoh-16.png" width="16" height="16"></a>
<a href="/~feoh">feoh</a>
<a href="/c/4l8mzk"><time title="2022-07-22 12:30:20" datetime="2022-07-22 12:30:20" data-at-unix="1658511020">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I really like this idea but have no idea what its implementation would represent in terms of additional load to the server.</p>
</div>
</div>
</div>
<ol class="comments">
<li class="comments_subtree">
<input id="comment_folder_qy0l9k"
class="comment_folder_button" type="checkbox"
>
<div id="c_qy0l9k"
data-shortid="qy0l9k"
class="comment
">
<div class="voters">
<label for="comment_folder_qy0l9k" class="comment_folder"></label>
<a class="upvoter" title="5" href="/login">5</a>
</div>
<div class="details">
<div class="byline">
<a name="c_qy0l9k"></a>
<span class="">
<a tabindex="-1" aria-hidden="true" href="/~pushcx"><img srcset="/avatars/pushcx-16.png 1x, /avatars/pushcx-32.png 2x" class="avatar" alt="pushcx avatar" loading="lazy" decoding="async" src="/avatars/pushcx-16.png" width="16" height="16"></a>
<a href="/~pushcx">pushcx</a>
<span class="hat hat_sysop" title="Granted 2017-10-15"><span class="crown">Sysop</span></span>
<a href="/c/qy0l9k"><time title="2022-07-22 16:12:09" datetime="2022-07-22 16:12:09" data-at-unix="1658524329">3 years ago</time></a>
</span>
<span class="flagger flagger_stub"></span>
<span class="reason">
</span>
</div>
<div role="heading" aria-level="3" class="comment_text">
<p>I've moved most of the <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L340" rel="ugc">vote</a> into the db, but <a href="https://github.com/lobsters/lobsters/blob/cf52ab9f3f0b3ac805effc2e718f15922cb67332/app/models/comment.rb#L219" rel="ugc">not all</a>. If <code>calculated_confidence</code> finished its move into the db almost any algorithm would be faster than the current implementation.</p>
</div>
</div>
</div>
<ol class="comments"></ol>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<br>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<div class="comment_form_container" data-shortid="">
<form action="/comments" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="AI0414bnzi152-mE0JTWEtwq5B0ZhALBW1W7rGiG5zR-sFaJjWzdARXFM7w_DbPQqWNjzh9bufWZbXG39v5T6g" autocomplete="off">
<input value="znlkib" autocomplete="off" type="hidden" name="story_id" id="story_id" />
<input value="post" autocomplete="off" type="hidden" name="_method" id="_method" />
<input value="edtrox" autocomplete="off" type="hidden" name="parent_comment_short_id" id="parent_comment_short_id" />
<div style="width: 100%;">
<textarea rows="5" placeholder="" required="required" name="comment" id="comment">
</textarea>
<div class="controls">
<div class="buttons">
<input type="submit" name="commit" value="Post" class="comment-post" data-disable-with="Post">
</div>
</div>
</div>
</form>
</div>
File diff suppressed because one or more lines are too long
+888
View File
@@ -0,0 +1,888 @@
<!doctype html>
<html lang="en" >
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<link rel="icon" sizes="144x144" type="image/png" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="57x57" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="114x114" href="/touch-icon.png">
<link rel="apple-touch-icon" sizes="72x72" href="/touch-icon-144.png">
<link rel="apple-touch-icon" sizes="144x144" href="/touch-icon-144.png">
<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="Lobsters">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="always">
<meta name="theme-color" content="#AC130D">
<meta name="story-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;A&quot;:&quot;Already Posted&quot;,&quot;B&quot;:&quot;Broken Link&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<meta name="comment-flags" content="{&quot;O&quot;:&quot;Off-topic&quot;,&quot;M&quot;:&quot;Me-too&quot;,&quot;T&quot;:&quot;Troll&quot;,&quot;U&quot;:&quot;Unkind&quot;,&quot;S&quot;:&quot;Spam&quot;,&quot;&quot;:&quot;Cancel&quot;}">
<link rel="shortcut icon" href="/favicon.ico">
<title>Tags | Lobsters</title>
<link rel="stylesheet" href="/assets/application-8d029e37.css">
<link rel="stylesheet" href="/assets/system-system-fd030c9d.css">
<link rel="stylesheet" href="/assets/tom-select-00e8031d.css" data-turbo-track="reload">
<link rel="stylesheet" href="/assets/TomSelect_remove_button-4d5c34b9.css" data-turbo-track="reload">
<meta name="csrf-param" content="authenticity_token">
<meta name="csrf-token" content="5RQPW51V6pxZ_NQGWgCTPRnjcXO4VrPQ_Jt7hv26BO_VAXRzgXTPKT6KNHv94jWwqnFtAiuSNiN3XZU114XTBA">
<meta name="robots" content="noai, noimageai">
<script type="speculationrules" nonce="UVrBK4O0eKZ+V0Dvv4TX7Q==">
{
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "moderate"
}
]
}
</script>
</head>
<body data-username='' data-now-unix='1780070764'>
<header id="nav">
<a id="logo" style="background-color: #6e0000;" href="/" title="Lobsters (Current traffic: 17%)"></a>
<div class="navholder">
<nav class="links">
<a class="" href="/active">Active</a>
<a class="" href="/recent">Recent</a>
<a class="" href="/comments">Comments</a>
<a class="" href="/search">Search</a>
<a class="corner" href="/login">Login</a>
</nav>
</div>
<nav class="corner">
<a class="corner" href="/login">Login</a>
</nav>
</header>
<div id="inside">
<h1 class="">Tags</h1>
<p>
You can <a href="/filters">filter out stories by tag</a>, even if you don't have an account.
Inactive tags can no longer be used on new stories.
</p>
<div class="tag-title">
<h2 id="compsci">
<a href="/categories/compsci">compsci</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="compsci">
<li>
<a class="tag tag_ai" title="Developing artificial intelligence, machine learning. Tag AI usage only with `vibecoding`." id="ai" href="/t/ai">ai</a>
<span class="">Developing artificial intelligence, machine learning. Tag AI usage only with `vibecoding`.</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_compsci" title="Other computer science/programming" id="compsci" href="/t/compsci">compsci</a>
<span class="">Other computer science/programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_distributed" title="Distributed systems" id="distributed" href="/t/distributed">distributed</a>
<span class="">Distributed systems</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_formalmethods" title="Formal methods" id="formalmethods" href="/t/formalmethods">formalmethods</a>
<span class="">Formal methods</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_graphics" title="Graphics programming" id="graphics" href="/t/graphics">graphics</a>
<span class="">Graphics programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_networking" title="Networking" id="networking" href="/t/networking">networking</a>
<span class="">Networking</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_osdev" title="Operating system design and development when no specific OS tag exists" id="osdev" href="/t/osdev">osdev</a>
<span class="">Operating system design and development when no specific OS tag exists</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_plt" title="Programming language theory, types, design" id="plt" href="/t/plt">plt</a>
<span class="">Programming language theory, types, design</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_programming" title="Use when every tag or no specific tag applies" id="programming" href="/t/programming">programming</a>
<span class="">Use when every tag or no specific tag applies</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="culture">
<a href="/categories/culture">culture</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="culture">
<li>
<a class="tag tag_culture" title="Technical communities and culture" id="culture" href="/t/culture">culture</a>
<span class="">Technical communities and culture</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_law" title="Law, patents, and licensing" id="law" href="/t/law">law</a>
<span class="">Law, patents, and licensing</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_person" title="Stories about particular persons" id="person" href="/t/person">person</a>
<span class="">Stories about particular persons</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_philosophy" title="Philosophy" id="philosophy" href="/t/philosophy">philosophy</a>
<span class="">Philosophy</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="field">
<a href="/categories/field">field</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="field">
<li>
<a class="tag tag_cogsci" title="Cognitive Science" id="cogsci" href="/t/cogsci">cogsci</a>
<span class="">Cognitive Science</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_cryptography" title="Cryptography" id="cryptography" href="/t/cryptography">cryptography</a>
<span class="">Cryptography</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_education" title="Education" id="education" href="/t/education">education</a>
<span class="">Education</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_finance" title="Finance and economics" id="finance" href="/t/finance">finance</a>
<span class="">Finance and economics</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_hardware" title="Hardware" id="hardware" href="/t/hardware">hardware</a>
<span class="">Hardware</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_math" title="Mathematics" id="math" href="/t/math">math</a>
<span class="">Mathematics</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_science" title="It&#39;s &quot;Science&quot;" id="science" href="/t/science">science</a>
<span class="">It&#39;s &quot;Science&quot;</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="format">
<a href="/categories/format">format</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="format">
<li>
<a class="tag tag_ask tag_is_media" title="Ask Lobsters" id="ask" href="/t/ask">ask</a>
<span class="">Ask Lobsters</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_audio tag_is_media" title="Link to audio (podcast, interview)" id="audio" href="/t/audio">audio</a>
<span class="">Link to audio (podcast, interview)</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_book tag_is_media" title="Link to a book (not an ad or review)" id="book" href="/t/book">book</a>
<span class="">Link to a book (not an ad or review)</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_pdf tag_is_media" title="Link to a PDF document" id="pdf" href="/t/pdf">pdf</a>
<span class="">Link to a PDF document</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_show tag_is_media" title="Show Lobsters / Projects" id="show" href="/t/show">show</a>
<span class="">Show Lobsters / Projects</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_slides tag_is_media" title="Slide deck" id="slides" href="/t/slides">slides</a>
<span class="">Slide deck</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_transcript tag_is_media" title="Includes transcript of audio or video" id="transcript" href="/t/transcript">transcript</a>
<span class="">Includes transcript of audio or video</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_video tag_is_media" title="Link to a video" id="video" href="/t/video">video</a>
<span class="">Link to a video</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="genre">
<a href="/categories/genre">genre</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="genre">
<li>
<a class="tag tag_art" title="Art" id="art" href="/t/art">art</a>
<span class="">Art</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_event" title="Events, conferences, and meetups" id="event" href="/t/event">event</a>
<span class="">Events, conferences, and meetups</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_historical" title="History and retrospectives (not for things that happen to be old)" id="historical" href="/t/historical">historical</a>
<span class="">History and retrospectives (not for things that happen to be old)</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_job" title="Employment/Internship opportunities" id="job" href="/t/job">job</a>
<span class="">Employment/Internship opportunities</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_news" title="General news and current events" id="news" href="/t/news">news</a>
<span class="inactive_tag">General news and current events</span>
<span class="byline">
<span aria-hidden="true"> | </span> <em>inactive</em>
</span>
</li>
<li>
<a class="tag tag_rant" title="Rants and raves" id="rant" href="/t/rant">rant</a>
<span class="">Rants and raves</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_release" title="Software releases and announcements" id="release" href="/t/release">release</a>
<span class="">Software releases and announcements</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_satire" title="Satire, parody, and other humor" id="satire" href="/t/satire">satire</a>
<span class="">Satire, parody, and other humor</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="interaction">
<a href="/categories/interaction">interaction</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="interaction">
<li>
<a class="tag tag_a11y" title="accessibility, assistive technology, standards" id="a11y" href="/t/a11y">a11y</a>
<span class="">accessibility, assistive technology, standards</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_design" title="Visual design" id="design" href="/t/design">design</a>
<span class="">Visual design</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_visualization" title="Data visualization" id="visualization" href="/t/visualization">visualization</a>
<span class="">Data visualization</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="languages">
<a href="/categories/languages">languages</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="languages">
<li>
<a class="tag tag_apl" title="Array Programming Languages such as APL, J, and K" id="apl" href="/t/apl">apl</a>
<span class="">Array Programming Languages such as APL, J, and K</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_assembly" title="Assembly programming" id="assembly" href="/t/assembly">assembly</a>
<span class="">Assembly programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_c" title="C programming" id="c" href="/t/c">c</a>
<span class="">C programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_c++" title="C++ programming" id="c++" href="/t/c++">c++</a>
<span class="">C++ programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_clojure" title="Clojure programming" id="clojure" href="/t/clojure">clojure</a>
<span class="">Clojure programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_concatenative" title=" stackbased languages like Forth, Factor, Joy" id="concatenative" href="/t/concatenative">concatenative</a>
<span class=""> stackbased languages like Forth, Factor, Joy</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_css" title="Cascading Style Sheets" id="css" href="/t/css">css</a>
<span class="">Cascading Style Sheets</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_d" title="D programming" id="d" href="/t/d">d</a>
<span class="">D programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_dotnet" title="C#, F#, .NET programming" id="dotnet" href="/t/dotnet">dotnet</a>
<span class="">C#, F#, .NET programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_elixir" title="Elixir programming" id="elixir" href="/t/elixir">elixir</a>
<span class="">Elixir programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_elm" title="Elm programming" id="elm" href="/t/elm">elm</a>
<span class="">Elm programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_erlang" title="Erlang development" id="erlang" href="/t/erlang">erlang</a>
<span class="">Erlang development</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_fortran" title="Fortran programming" id="fortran" href="/t/fortran">fortran</a>
<span class="">Fortran programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_gleam" title="Strongly-typed BEAM language" id="gleam" href="/t/gleam">gleam</a>
<span class="">Strongly-typed BEAM language</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_go" title="Golang programming" id="go" href="/t/go">go</a>
<span class="">Golang programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_haskell" title="Haskell programming" id="haskell" href="/t/haskell">haskell</a>
<span class="">Haskell programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_java" title="Java programming" id="java" href="/t/java">java</a>
<span class="">Java programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_javascript" title="Javascript programming" id="javascript" href="/t/javascript">javascript</a>
<span class="">Javascript programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_kotlin" title="Kotlin programming" id="kotlin" href="/t/kotlin">kotlin</a>
<span class="">Kotlin programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_lisp" title="Lisp and Scheme programming" id="lisp" href="/t/lisp">lisp</a>
<span class="">Lisp and Scheme programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_logiclangs" title="Prolog, Datalog, Z3, Picat, miniKanren, ASP etc. using clauses" id="logiclangs" href="/t/logiclangs">logiclangs</a>
<span class="">Prolog, Datalog, Z3, Picat, miniKanren, ASP etc. using clauses</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_lua" title="Lua programming" id="lua" href="/t/lua">lua</a>
<span class="">Lua programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_ml" title="MetaLanguage, OCaml programming" id="ml" href="/t/ml">ml</a>
<span class="">MetaLanguage, OCaml programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_nodejs" title="Node.js programming" id="nodejs" href="/t/nodejs">nodejs</a>
<span class="">Node.js programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_objectivec" title="Objective-C programming" id="objectivec" href="/t/objectivec">objectivec</a>
<span class="">Objective-C programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_perl" title="Perl programming" id="perl" href="/t/perl">perl</a>
<span class="">Perl programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_php" title="PHP programming" id="php" href="/t/php">php</a>
<span class="">PHP programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_python" title="Python programming" id="python" href="/t/python">python</a>
<span class="">Python programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_ruby" title="Ruby programming" id="ruby" href="/t/ruby">ruby</a>
<span class="">Ruby programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_rust" title="Rust programming" id="rust" href="/t/rust">rust</a>
<span class="">Rust programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_scala" title="Scala programming" id="scala" href="/t/scala">scala</a>
<span class="">Scala programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_swift" title="Swift programming" id="swift" href="/t/swift">swift</a>
<span class="">Swift programming</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_zig" title="Zig programming" id="zig" href="/t/zig">zig</a>
<span class="">Zig programming</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="lobsters">
<a href="/categories/lobsters">lobsters</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="lobsters">
<li>
<a class="tag tag_announce" title="Site anouncement" id="announce" href="/t/announce">announce</a>
<span class="">Site anouncement</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_interview" title="Lobsters interviews" id="interview" href="/t/interview">interview</a>
<span class="">Lobsters interviews</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_meta" title="Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters" id="meta" href="/t/meta">meta</a>
<span class="">Lobsters-related bikeshedding - report bugs at https://github.com/lobsters/lobsters</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="os">
<a href="/categories/os">os</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="os">
<li>
<a class="tag tag_android" title="Android" id="android" href="/t/android">android</a>
<span class="">Android</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_dragonflybsd" title="DragonFly BSD" id="dragonflybsd" href="/t/dragonflybsd">dragonflybsd</a>
<span class="">DragonFly BSD</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_freebsd" title="FreeBSD" id="freebsd" href="/t/freebsd">freebsd</a>
<span class="">FreeBSD</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_illumos" title="illumos" id="illumos" href="/t/illumos">illumos</a>
<span class="">illumos</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_ios" title="Apple iOS" id="ios" href="/t/ios">ios</a>
<span class="">Apple iOS</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_linux" title="Linux" id="linux" href="/t/linux">linux</a>
<span class="">Linux</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_mac" title="Apple macOS" id="mac" href="/t/mac">mac</a>
<span class="">Apple macOS</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_netbsd" title="NetBSD" id="netbsd" href="/t/netbsd">netbsd</a>
<span class="">NetBSD</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_nix" title="package manager, distribution, and related systems like guix" id="nix" href="/t/nix">nix</a>
<span class="">package manager, distribution, and related systems like guix</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_openbsd" title="OpenBSD" id="openbsd" href="/t/openbsd">openbsd</a>
<span class="">OpenBSD</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_unix" title="*nix" id="unix" href="/t/unix">unix</a>
<span class="">*nix</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_windows" title="Windows" id="windows" href="/t/windows">windows</a>
<span class="">Windows</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="platforms">
<a href="/categories/platforms">platforms</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="platforms">
<li>
<a class="tag tag_browsers" title="Web browsers" id="browsers" href="/t/browsers">browsers</a>
<span class="">Web browsers</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_email" title="e-mail" id="email" href="/t/email">email</a>
<span class="">e-mail</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_games" title="Game design and study" id="games" href="/t/games">games</a>
<span class="">Game design and study</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_ipv6" title="IPv6" id="ipv6" href="/t/ipv6">ipv6</a>
<span class="inactive_tag">IPv6</span>
<span class="byline">
<span aria-hidden="true"> | </span> <em>inactive</em>
</span>
</li>
<li>
<a class="tag tag_merkle-trees" title="And related similar data structures. Not business/scam news." id="merkle-trees" href="/t/merkle-trees">merkle-trees</a>
<span class="">And related similar data structures. Not business/scam news.</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_mobile" title="Mobile app/web development" id="mobile" href="/t/mobile">mobile</a>
<span class="">Mobile app/web development</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_retrocomputing" title="Reproductions, emulators, and other revivals of historic technologies" id="retrocomputing" href="/t/retrocomputing">retrocomputing</a>
<span class="">Reproductions, emulators, and other revivals of historic technologies</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_wasm" title="webassembly" id="wasm" href="/t/wasm">wasm</a>
<span class="">webassembly</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_web" title="Web development and news" id="web" href="/t/web">web</a>
<span class="">Web development and news</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="practices">
<a href="/categories/practices">practices</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="practices">
<li>
<a class="tag tag_api" title="API development/implementation" id="api" href="/t/api">api</a>
<span class="">API development/implementation</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_debugging" title="Debugging techniques" id="debugging" href="/t/debugging">debugging</a>
<span class="">Debugging techniques</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_devops" title="DevOps" id="devops" href="/t/devops">devops</a>
<span class="">DevOps</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_performance" title="Performance and optimization" id="performance" href="/t/performance">performance</a>
<span class="">Performance and optimization</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_practices" title="Development and team practices" id="practices" href="/t/practices">practices</a>
<span class="">Development and team practices</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_privacy" title="Privacy" id="privacy" href="/t/privacy">privacy</a>
<span class="">Privacy</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_reversing" title="Reverse engineering" id="reversing" href="/t/reversing">reversing</a>
<span class="">Reverse engineering</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_scaling" title="Scaling and architecture" id="scaling" href="/t/scaling">scaling</a>
<span class="">Scaling and architecture</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_security" title="Netsec, appsec, and infosec" id="security" href="/t/security">security</a>
<span class="">Netsec, appsec, and infosec</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_testing" title="Software testing" id="testing" href="/t/testing">testing</a>
<span class="">Software testing</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_virtualization" title="Virtualization" id="virtualization" href="/t/virtualization">virtualization</a>
<span class="">Virtualization</span>
<span class="byline">
</span>
</li>
</ol>
<div class="tag-title">
<h2 id="tools">
<a href="/categories/tools">tools</a>
</h2>
</div>
<ol class="category_tags" aria-labelledby="tools">
<li>
<a class="tag tag_compilers" title="Compiler design" id="compilers" href="/t/compilers">compilers</a>
<span class="">Compiler design</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_databases" title="Databases (SQL, NoSQL)" id="databases" href="/t/databases">databases</a>
<span class="">Databases (SQL, NoSQL)</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_editors" title="Code editors and IDEs without a specific tag" id="editors" href="/t/editors">editors</a>
<span class="">Code editors and IDEs without a specific tag</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_emacs" title="Emacs editor" id="emacs" href="/t/emacs">emacs</a>
<span class="">Emacs editor</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_systemd" title="Linux systemd" id="systemd" href="/t/systemd">systemd</a>
<span class="">Linux systemd</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_vcs" title="Git, Mercurial, Perforce, Subversion, Jujutsu and other version control systems" id="vcs" href="/t/vcs">vcs</a>
<span class="">Git, Mercurial, Perforce, Subversion, Jujutsu and other version control systems</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_vibecoding" title="Using AI/LLM, coding tools. Don&#39;t also tag with `ai`." id="vibecoding" href="/t/vibecoding">vibecoding</a>
<span class="">Using AI/LLM, coding tools. Don&#39;t also tag with `ai`.</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_vim" title="Vim editor" id="vim" href="/t/vim">vim</a>
<span class="">Vim editor</span>
<span class="byline">
</span>
</li>
<li>
<a class="tag tag_vscode" title="Visual Studio Code editor" id="vscode" href="/t/vscode">vscode</a>
<span class="">Visual Studio Code editor</span>
<span class="byline">
</span>
</li>
</ol>
</div>
<footer>
<span id="office-hours-live">
</span>
<nav>
<a href="/about">About</a>
<a href="/tags">Tags</a>
<a href="/filters">Filter</a>
<a href="/moderations">Moderation Log</a>
</nav>
</footer>
<span id="iab-pcm-sdk"></span><span id="iab-autofill-sdk"></span>
</body>
</html>
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -8,6 +8,7 @@
plugins {
alias(libs.plugins.android.test)
id("dev.msfjarvis.claw.android-common")
id("dev.msfjarvis.claw.kotlin-android")
alias(libs.plugins.baselineprofile)
alias(libs.plugins.dependencyAnalysis)
@@ -16,7 +17,6 @@ plugins {
android {
namespace = "dev.msfjarvis.claw.benchmark"
compileSdk = 36
defaultConfig {
minSdk = 28
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -30,11 +30,23 @@ android {
}
}
targetProjectPath = ":android"
testOptions {
managedDevices {
localDevices {
create("pixel9api36") {
device = "Pixel 9"
apiLevel = 36
systemImageSource = "aosp"
}
}
}
}
}
baselineProfile {
useConnectedDevices = true
useConnectedDevices = false
enableEmulatorDisplay = false
managedDevices += "pixel9api36"
}
dependencies {
@@ -30,7 +30,7 @@ class AndroidCommonPlugin : Plugin<Project> {
project.configureSlimTests()
project.pluginManager.apply(AndroidCacheFixPlugin::class)
project.extensions.configure<CommonExtension> {
compileSdk { version = release(36) }
compileSdk { version = release(COMPILE_SDK) }
defaultConfig.apply {
// Required by Metro, I don't care for this to be more broadly usable at the expense of my
// personal development experience.
@@ -0,0 +1,10 @@
/*
* 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.gradle
const val COMPILE_SDK = 37
const val TARGET_SDK = 37
@@ -23,7 +23,7 @@ class ApplicationPlugin : Plugin<Project> {
project.pluginManager.apply(AppPlugin::class)
project.pluginManager.apply(AndroidCommonPlugin::class)
project.extensions.configure<ApplicationExtension> {
defaultConfig { targetSdk = 36 }
defaultConfig { targetSdk = TARGET_SDK }
dependenciesInfo {
includeInBundle = false
includeInApk = false
@@ -36,18 +36,17 @@ class ApplicationPlugin : Plugin<Project> {
buildTypes.configureEach {
setProguardFiles(
listOf(
"proguard-android-optimize.pro",
"proguard-rules.pro",
"proguard-rules-missing-classes.pro",
)
listOf("r8-android-optimize.pro", "r8-rules.pro", "r8-rules-missing-classes.pro")
)
if (name == "release") {
isMinifyEnabled = !project.providers.environmentVariable("DISABLE_MINIFY").isPresent
} else if (name == "debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isMinifyEnabled = false
when (name) {
"release" -> {
isMinifyEnabled = !project.providers.environmentVariable("DISABLE_MINIFY").isPresent
}
"debug" -> {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isMinifyEnabled = false
}
}
}
project.configureBuildSigning()
@@ -39,12 +39,10 @@ class KotlinCommonPlugin : Plugin<Project> {
listOf(
"-opt-in=kotlin.RequiresOptIn",
"-Xjspecify-annotations=strict",
"-Xtype-enhancement-improvements-strict-mode",
"-Xcontext-parameters",
// TODO trips in SQLDelight code: https://github.com/sqldelight/sqldelight/issues/6029
// "-Xreturn-value-checker=full",
"-Xcontext-sensitive-resolution",
"-Xdata-flow-based-exhaustiveness",
"-Xwhen-expressions=indy",
"-Xexplicit-backing-fields",
)
@@ -30,6 +30,8 @@ object LintConfig {
error += "ComposeM2Api"
// The Lint baseline message changes too frequently for this
disable += "Aligned16KB"
// This is already giving me false positives
disable += "ComposeRedundantComposable"
}
baseline = project.file("lint-baseline.xml")
// This is extremely annoying
@@ -50,6 +50,6 @@ class SpotlessPlugin : Plugin<Project> {
}
private companion object {
private const val KTFMT_VERSION = "0.61"
private const val KTFMT_VERSION = "0.63"
}
}
@@ -16,7 +16,7 @@ import org.gradle.kotlin.dsl.exclude
/** Extension function to configure JUnit5 dependencies with the Truth assertion library. */
fun DependencyHandlerScope.addTestDependencies(project: Project) {
val libs = project.extensions.getByName("libs") as LibrariesForLibs
arrayOf("test", "screenshotTest")
arrayOf("test")
.filter { sourceSet -> project.configurations.findByName("${sourceSet}Implementation") != null }
.forEach { sourceSet ->
addProvider("${sourceSet}Implementation", platform(libs.junit.bom))
+13 -11
View File
@@ -11,25 +11,30 @@ import dev.msfjarvis.claw.gradle.addTestDependencies
plugins {
id("dev.msfjarvis.claw.android-library")
id("dev.msfjarvis.claw.kotlin-android")
alias(libs.plugins.screenshot)
alias(libs.plugins.kotlin.composeCompiler)
alias(libs.plugins.dependencyAnalysis)
alias(libs.plugins.metro)
alias(libs.plugins.poko)
}
android {
androidResources.enable = true
buildFeatures { compose = true }
experimentalProperties["android.experimental.enableScreenshotTest"] = true
namespace = "dev.msfjarvis.claw.common"
}
dependencies {
api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material3)
api(libs.androidx.compose.runtime)
api(libs.androidx.compose.ui)
api(libs.androidx.compose.ui.graphics)
api(libs.androidx.datastore)
api(libs.androidx.datastore.core)
api(libs.androidx.datastore.preferences.core)
api(libs.androidx.lifecycle.viewmodel)
api(libs.kotlinx.coroutines.core)
api(libs.metrox.viewmodel)
api(projects.api)
api(projects.core)
api(projects.database.core)
@@ -40,16 +45,12 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.animation.core)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.annotation)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.unit)
implementation(libs.androidx.core)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.common)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -59,14 +60,12 @@ dependencies {
implementation(libs.coil3.plain)
implementation(libs.eithernet)
implementation(libs.htmlconverter)
implementation(libs.jsoup)
implementation(libs.ksoup)
implementation(libs.kotlinResult)
implementation(libs.kotlinResult.coroutines)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.metrox.viewmodel)
implementation(libs.kotlinx.datetime)
implementation(libs.metrox.viewmodel.compose)
implementation(libs.sentry.android.core)
implementation(libs.sqldelight.runtime)
implementation(libs.sqldelight.extensions.coroutines)
@@ -77,5 +76,8 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.sqldelight.jvmDriver)
screenshotTestImplementation(libs.screenshot.validation.api)
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
addTestDependencies(project)
}
@@ -6,8 +6,14 @@
*/
package dev.msfjarvis.claw.common.comments
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,23 +23,36 @@ 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
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.github.michaelbull.result.coroutines.runSuspendCatching
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.onOk
import dev.msfjarvis.claw.common.R
import dev.msfjarvis.claw.common.comments.reply.plainTextFromHtml
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.PostTitle
import dev.msfjarvis.claw.common.posts.Submitter
@@ -42,6 +61,7 @@ import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.LinkMetadata
import dev.msfjarvis.claw.model.UIPost
import kotlin.time.Instant
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -54,7 +74,7 @@ internal fun CommentsHeader(
val linkMetadata by
produceState(initialValue = LinkMetadata(post.url, null), key1 = post) {
runSuspendCatching { postActions.getLinkMetadata(post.url) }
.onSuccess { metadata -> value = metadata }
.onOk { metadata -> value = metadata }
}
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
@@ -62,7 +82,7 @@ internal fun CommentsHeader(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PostTitle(title = post.title, isRead = false, singleLineTitle = false)
PostTitle(title = post.title, isRead = false)
TagRow(tags = post.tags.toImmutableList())
Spacer(Modifier.height(4.dp))
@@ -119,3 +139,210 @@ private fun PostLink(linkMetadata: LinkMetadata, modifier: Modifier = Modifier)
}
}
}
private val ThreadIndentWidth = 10.dp
private val ThreadGuideOffset = 5.dp
private val ThreadGuideWidth = 1.5.dp
@Composable
internal fun CommentEntry(
isExpanded: Boolean,
commentNode: CommentNode,
openUserProfile: (String) -> Unit,
onToggleExpandedState: (String, Boolean) -> Unit,
isLoggedIn: Boolean,
upvoteComment: (String) -> Unit,
unvoteComment: (String) -> Unit,
onReply: (String, String) -> Unit,
modifier: Modifier = Modifier,
) {
val comment = commentNode.comment
var hasLocallyUpvoted by remember(comment.shortId) { mutableStateOf(comment.isUpvoted) }
var isActionBarExpanded by
remember(comment.shortId) { mutableStateOf(commentNode.indentLevel == 0) }
val score =
displayScore(
score = comment.score,
initiallyUpvoted = comment.isUpvoted,
isUpvoted = hasLocallyUpvoted,
)
val theme = if (isSystemInDarkTheme()) ThemeMode.DARK else ThemeMode.LIGHT
val indentGuideLevel = commentNode.indentLevel.minus(1).coerceAtLeast(0)
Box(
modifier =
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.drawBehind {
if (indentGuideLevel > 0) {
repeat(indentGuideLevel) { level ->
val x = ThreadIndentWidth.toPx() * level + ThreadGuideOffset.toPx()
drawLine(
color =
CommentTreeColors.colorForDepth(
depth = level,
theme = theme,
),
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = ThreadGuideWidth.toPx(),
cap = StrokeCap.Round,
)
}
}
}
.padding(start = ThreadIndentWidth * indentGuideLevel)
) {
Column(
modifier =
Modifier.fillMaxWidth()
.background(
if (commentNode.isUnread) {
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.background
}
)
.combinedClickable(
onClick = {
if (isExpanded) {
if (isLoggedIn) isActionBarExpanded = !isActionBarExpanded
} else {
onToggleExpandedState(comment.shortId, true)
}
},
onLongClick = {
isActionBarExpanded = false
onToggleExpandedState(comment.shortId, !isExpanded)
},
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier =
Modifier.fillMaxWidth()
.padding(
start = 16.dp,
end = 16.dp,
top = 12.dp,
bottom = if (!isExpanded) 12.dp else 0.dp,
),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = comment.user,
style = MaterialTheme.typography.labelLarge,
color =
if (commentNode.isPostAuthor) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.onSurfaceVariant,
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,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = buildCommentAgeString(comment.timestamp, comment.edited),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isExpanded) {
ThemedRichText(
text = comment.comment,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
)
AnimatedVisibility(
visible = isActionBarExpanded,
enter = expandVertically(expandFrom = Alignment.Top),
exit = shrinkVertically(shrinkTowards = Alignment.Top),
) {
CommentActionTray(
isUpvoted = hasLocallyUpvoted,
onVoteClick = {
if (hasLocallyUpvoted) {
unvoteComment(comment.shortId)
} else {
upvoteComment(comment.shortId)
}
hasLocallyUpvoted = !hasLocallyUpvoted
},
onReplyClick = {
onReply(comment.shortId, plainTextFromHtml(comment.comment))
},
)
}
}
}
}
}
@Composable
private fun CommentActionTray(
isUpvoted: Boolean,
onVoteClick: () -> Unit,
onReplyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.KeyboardArrowUp,
contentDescription = if (isUpvoted) "Remove upvote" else "Upvote",
tint =
if (isUpvoted) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(role = Role.Button, onClick = onVoteClick),
)
Spacer(Modifier.size(12.dp))
Icon(
imageVector = Icons.AutoMirrored.Outlined.Reply,
contentDescription = "Reply",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(role = Role.Button, onClick = onReplyClick),
)
}
}
private fun displayScore(score: Int, initiallyUpvoted: Boolean, isUpvoted: Boolean): Int {
return when {
initiallyUpvoted == isUpvoted -> score
isUpvoted -> score + 1
else -> score - 1
}
}
private fun buildCommentAgeString(timestamp: Instant, edited: Boolean): String {
val now = System.currentTimeMillis()
val relativeTime =
DateUtils.getRelativeTimeSpanString(
timestamp.toEpochMilliseconds(),
now,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
)
return if (edited) "$relativeTime · edited" else relativeTime.toString()
}
@@ -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
@@ -0,0 +1,64 @@
/*
* 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.common.comments
import androidx.compose.ui.graphics.Color
enum class ThemeMode {
LIGHT,
DARK,
}
object CommentTreeColors {
/*
* Light theme:
* Lower luminance, lower chroma.
* Prevents neon appearance on white backgrounds.
*/
private val lightPalette =
listOf(
Color(0x3D5A80FF), // muted blue
Color(0x6D597AFF), // muted violet
Color(0xB56576FF), // dusty rose
Color(0x9C6644FF), // brown amber
Color(0x4D7C59FF), // muted green
Color(0x4D6A6DFF), // desaturated cyan
Color(0x6B705CFF), // olive
Color(0x9A6B4FFF), // muted orange
)
/*
* Dark theme:
* Higher luminance required for visibility,
* but still slightly desaturated.
*/
private val darkPalette =
listOf(
Color(0x7EAEEFFF), // soft blue
Color(0xB8A1E3FF), // lavender
Color(0xE598A8FF), // soft rose
Color(0xD9A066FF), // amber
Color(0x7BC99FFF), // emerald
Color(0x7FC8D6FF), // cyan
Color(0xB7C77AFF), // lime
Color(0xE0A37AFF), // orange
)
fun colorForDepth(
depth: Int,
theme: ThemeMode,
): Color {
val palette =
when (theme) {
ThemeMode.LIGHT -> lightPalette
ThemeMode.DARK -> darkPalette
}
return palette[depth.mod(palette.size)]
}
}
@@ -6,7 +6,6 @@
*/
package dev.msfjarvis.claw.common.comments
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -31,13 +30,21 @@ internal class CommentsHandler {
_visibleNodes.value = flatList
}
private fun isUnread(commentId: String, seenCommentsState: SeenCommentsState) =
when (seenCommentsState) {
SeenCommentsState.Loading -> false
SeenCommentsState.NoBaseline -> false
is SeenCommentsState.BaselineLoaded -> {
!seenCommentsState.postComments.commentIds.contains(commentId)
}
}
fun createListNode(
comments: List<Comment>,
commentState: PostComments?,
seenCommentsState: SeenCommentsState,
isPostAuthor: (Comment) -> Boolean,
) {
val commentNodes = mutableListOf<CommentNode>()
val isUnread = { id: String -> commentState?.commentIds?.contains(id) != true }
for (i in comments.indices) {
val comment = comments[i]
@@ -46,7 +53,7 @@ internal class CommentsHandler {
CommentNode(
comment = comment,
isPostAuthor = isPostAuthor(comment),
isUnread = isUnread(comment.shortId),
isUnread = isUnread(comment.shortId, seenCommentsState),
indentLevel = 1,
isExpanded = !collapsedCommentIds.contains(comment.shortId),
)
@@ -57,7 +64,7 @@ internal class CommentsHandler {
CommentNode(
comment = comment,
isPostAuthor = isPostAuthor(comment),
isUnread = isUnread(comment.shortId),
isUnread = isUnread(comment.shortId, seenCommentsState),
indentLevel = commentNode.indentLevel + 1,
isExpanded = !collapsedCommentIds.contains(comment.shortId),
)
@@ -96,11 +103,11 @@ internal class CommentsHandler {
collapsedCommentIds = collectCollapsedIds(rootNodes)
}
fun updateUnreadStatus(commentState: PostComments?) {
val seenCommentIds = commentState?.commentIds ?: emptySet()
fun updateUnreadStatus(seenCommentsState: SeenCommentsState) {
if (seenCommentsState == SeenCommentsState.Loading) return
fun updateNode(node: CommentNode): CommentNode {
val isUnread = !seenCommentIds.contains(node.comment.shortId)
val isUnread = isUnread(node.comment.shortId, seenCommentsState)
val updatedChildren = node.children.map { updateNode(it) }.toMutableList()
return node.copy(isUnread = isUnread, children = updatedChildren)
}
@@ -16,13 +16,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.msfjarvis.claw.common.NetworkState.Error
import dev.msfjarvis.claw.common.NetworkState.Loading
import dev.msfjarvis.claw.common.NetworkState.Success
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.UIPost
import dev.zacsweers.metrox.viewmodel.metroViewModel
@@ -33,32 +33,48 @@ fun CommentsPage(
postActions: PostActions,
contentPadding: PaddingValues,
openUserProfile: (String) -> Unit,
openReplyScreen: (postId: String, commentId: String) -> Unit,
modifier: Modifier = Modifier,
viewModel: CommentsViewModel = metroViewModel(key = postId),
) {
LaunchedEffect(postId) { viewModel.loadPostDetails(postId) }
val commentState by
produceState<PostComments?>(initialValue = null, key1 = postId) {
value = viewModel.getSeenComments(postId)
val seenCommentsState by
produceState<SeenCommentsState>(initialValue = SeenCommentsState.Loading, key1 = postId) {
value =
SeenCommentsState.from(postComments = viewModel.getSeenComments(postId), hasLoaded = true)
}
val commentListState = rememberLazyListState()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle()
when (val postDetails = viewModel.postDetails) {
is Success<*> -> {
CommentsPageInternal(
details = (postDetails as Success<UIPost>).data,
postActions = postActions,
commentState = commentState,
markSeenComments = viewModel::markSeenComments,
openUserProfile = openUserProfile,
contentPadding = contentPadding,
commentListState = commentListState,
commentNodes = viewModel.commentNodes,
createCommentNodes = viewModel::createCommentNodes,
updateCommentNodeExpanded = viewModel::updateCommentNodeExpanded,
updateUnreadStatus = viewModel::updateUnreadStatus,
modifier = modifier.fillMaxSize(),
)
if (seenCommentsState != SeenCommentsState.Loading) {
CommentsPageInternal(
details = (postDetails as Success<UIPost>).data,
postActions = postActions,
seenCommentsState = seenCommentsState,
markSeenComments = viewModel::markSeenComments,
openUserProfile = openUserProfile,
contentPadding = contentPadding,
commentListState = commentListState,
commentNodes = viewModel.commentNodes,
createCommentNodes = viewModel::createCommentNodes,
updateCommentNodeExpanded = viewModel::updateCommentNodeExpanded,
updateUnreadStatus = viewModel::updateUnreadStatus,
isLoggedIn = isLoggedIn,
upvoteComment = viewModel::upvoteComment,
unvoteComment = viewModel::unvoteComment,
openReplyScreen = { commentId, commentText ->
viewModel.storeCommentText(commentId, commentText)
openReplyScreen(postId, commentId)
},
modifier = modifier.fillMaxSize(),
)
} else {
Box(modifier = Modifier.fillMaxSize()) {
ProgressBar(modifier = Modifier.align(Alignment.Center))
}
}
}
is Error -> {
Box(modifier = Modifier.fillMaxSize()) {
@@ -6,80 +6,82 @@
*/
package dev.msfjarvis.claw.common.comments
import android.text.format.DateUtils
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.drewhamilton.poko.Poko
import dev.msfjarvis.claw.common.R
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.Submitter
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.model.Comment
import dev.msfjarvis.claw.model.UIPost
import java.time.Instant
import java.time.temporal.TemporalAccessor
import kotlinx.coroutines.flow.StateFlow
@Poko internal class CommentsVisitDecision(val unreadCount: Int, val shouldPersist: Boolean)
internal fun buildCommentsVisitDecision(
currentCommentIds: List<String>,
seenCommentsState: SeenCommentsState,
): CommentsVisitDecision {
return when (seenCommentsState) {
SeenCommentsState.Loading -> CommentsVisitDecision(unreadCount = 0, shouldPersist = false)
SeenCommentsState.NoBaseline -> CommentsVisitDecision(unreadCount = 0, shouldPersist = true)
is SeenCommentsState.BaselineLoaded -> {
val seenCommentIds = seenCommentsState.postComments.commentIds.toSet()
CommentsVisitDecision(
unreadCount = currentCommentIds.count { it !in seenCommentIds },
shouldPersist = true,
)
}
}
}
@Composable
internal fun CommentsPageInternal(
details: UIPost,
postActions: PostActions,
commentState: PostComments?,
seenCommentsState: SeenCommentsState,
markSeenComments: (String, List<Comment>) -> Unit,
openUserProfile: (String) -> Unit,
contentPadding: PaddingValues,
commentListState: LazyListState,
commentNodes: StateFlow<List<CommentNode>>,
createCommentNodes: (UIPost, PostComments?) -> Unit,
createCommentNodes: (UIPost, SeenCommentsState) -> Unit,
updateCommentNodeExpanded: (String, Boolean) -> Unit,
updateUnreadStatus: (PostComments?) -> Unit,
updateUnreadStatus: (SeenCommentsState) -> Unit,
isLoggedIn: Boolean,
upvoteComment: (String) -> Unit,
unvoteComment: (String) -> Unit,
openReplyScreen: (String, String) -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(key1 = details) { createCommentNodes(details, commentState) }
LaunchedEffect(key1 = details, key2 = seenCommentsState) {
createCommentNodes(details, seenCommentsState)
}
LaunchedEffect(key1 = commentState) { updateUnreadStatus(commentState) }
LaunchedEffect(key1 = seenCommentsState) { updateUnreadStatus(seenCommentsState) }
val onToggleExpandedState = { shortId: String, isExpanded: Boolean ->
updateCommentNodeExpanded(shortId, isExpanded)
@@ -88,19 +90,23 @@ internal fun CommentsPageInternal(
val context = LocalContext.current
val commentNodes by commentNodes.collectAsStateWithLifecycle()
LaunchedEffect(key1 = details, key2 = commentState) {
if (details.comments.isNotEmpty() && !commentState?.commentIds.isNullOrEmpty()) {
val unreadCount = details.comments.size - commentState.commentIds.size
if (unreadCount > 0) {
val text =
when (unreadCount) {
1 -> "$unreadCount unread comment"
else -> "$unreadCount unread comments"
}
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
LaunchedEffect(key1 = details, key2 = seenCommentsState) {
val decision =
buildCommentsVisitDecision(
currentCommentIds = details.comments.map(Comment::shortId),
seenCommentsState = seenCommentsState,
)
if (decision.unreadCount > 0) {
val text =
when (decision.unreadCount) {
1 -> "${decision.unreadCount} unread comment"
else -> "${decision.unreadCount} unread comments"
}
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
if (decision.shouldPersist) {
markSeenComments(details.shortId, details.comments)
}
markSeenComments(details.shortId, details.comments)
}
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
@@ -124,7 +130,16 @@ internal fun CommentsPageInternal(
}
items(items = commentNodes, key = { node -> node.comment.shortId }) { node ->
NodeBox(node, node.isExpanded, openUserProfile, onToggleExpandedState)
NodeBox(
node = node,
isExpanded = node.isExpanded,
openUserProfile = openUserProfile,
onToggleExpandedState = onToggleExpandedState,
isLoggedIn = isLoggedIn,
upvoteComment = upvoteComment,
unvoteComment = unvoteComment,
openReplyScreen = openReplyScreen,
)
}
item(key = "bottom_spacer") {
@@ -151,6 +166,10 @@ private fun NodeBox(
isExpanded: Boolean,
openUserProfile: (String) -> Unit,
onToggleExpandedState: (String, Boolean) -> Unit,
isLoggedIn: Boolean,
upvoteComment: (String) -> Unit,
unvoteComment: (String) -> Unit,
openReplyScreen: (String, String) -> Unit,
modifier: Modifier = Modifier,
) {
CommentEntry(
@@ -158,113 +177,11 @@ private fun NodeBox(
commentNode = node,
openUserProfile = openUserProfile,
onToggleExpandedState = onToggleExpandedState,
isLoggedIn = isLoggedIn,
upvoteComment = upvoteComment,
unvoteComment = unvoteComment,
onReply = openReplyScreen,
modifier = modifier,
)
HorizontalDivider()
}
private val CommentEntryPadding = 16f.dp
@Composable
private fun CommentEntry(
isExpanded: Boolean,
commentNode: CommentNode,
openUserProfile: (String) -> Unit,
onToggleExpandedState: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val comment = commentNode.comment
Box(
modifier =
modifier
.fillMaxWidth()
.background(
if (commentNode.isUnread) MaterialTheme.colorScheme.surfaceContainerHigh
else MaterialTheme.colorScheme.background
)
.padding(
start = CommentEntryPadding * commentNode.indentLevel,
end = CommentEntryPadding,
top = CommentEntryPadding,
bottom = CommentEntryPadding,
)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row {
Box(
Modifier.padding(end = 8.dp).size(24.dp).clickable(role = Role.Button) {
onToggleExpandedState(comment.shortId, !isExpanded)
}
) {
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (isExpanded) "Collapse comment" else "Expand comment",
modifier = Modifier.align(Alignment.Center),
)
}
Submitter(
text =
buildCommenterString(
commenterName = comment.user,
score = comment.score,
createdAt = comment.createdAt,
lastEditedAt = comment.lastEditedAt,
nameColorOverride =
if (commentNode.isPostAuthor) MaterialTheme.colorScheme.tertiary else null,
),
avatarUrl = "https://lobste.rs/avatars/${comment.user}-100.png",
contentDescription = stringResource(R.string.user_avatar_for, comment.user),
modifier = Modifier.clickable { openUserProfile(comment.user) },
)
}
if (isExpanded) {
ThemedRichText(text = comment.comment, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
private fun buildCommenterString(
commenterName: String,
score: Int,
createdAt: TemporalAccessor,
lastEditedAt: TemporalAccessor,
nameColorOverride: Color? = null,
): AnnotatedString {
val now = System.currentTimeMillis()
val createdRelative =
DateUtils.getRelativeTimeSpanString(
Instant.from(createdAt).toEpochMilli(),
now,
DateUtils.MINUTE_IN_MILLIS,
)
val lastEditedRelative =
DateUtils.getRelativeTimeSpanString(
Instant.from(lastEditedAt).toEpochMilli(),
now,
DateUtils.MINUTE_IN_MILLIS,
)
return buildAnnotatedString {
if (nameColorOverride != null) {
withStyle(SpanStyle(color = nameColorOverride)) { append(commenterName) }
} else {
append(commenterName)
}
append(' ')
append('•')
append(' ')
append("$score points")
append(' ')
append('•')
append(' ')
append(createdRelative.toString())
if (lastEditedRelative != createdRelative) {
append(' ')
append('(')
append("Edited")
append(' ')
append(lastEditedRelative.toString())
append(')')
}
}
}
@@ -15,11 +15,13 @@ import com.github.michaelbull.result.coroutines.runSuspendCatching
import com.github.michaelbull.result.fold
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.msfjarvis.claw.api.AuthenticatedLobstersApi
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.toError
import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.common.comments.reply.CommentTextHolder
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.core.network.SessionCookieStore
import dev.msfjarvis.claw.model.Comment
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toUIPost
@@ -29,16 +31,21 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Inject
@ViewModelKey(CommentsViewModel::class)
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class CommentsViewModel(
private val api: LobstersApi,
private val authenticatedApi: AuthenticatedLobstersApi,
private val commentsRepository: CommentsRepository,
private val commentTextHolder: CommentTextHolder,
private val sessionCookieStore: SessionCookieStore,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val commentsHandler = CommentsHandler()
@@ -48,6 +55,15 @@ class CommentsViewModel(
internal val commentNodes: StateFlow<List<CommentNode>> = commentsHandler.listItems
val isLoggedIn: StateFlow<Boolean> =
sessionCookieStore
.isLoggedIn()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
suspend fun loadPostDetails(postId: String) {
if (postDetails is NetworkState.Error) {
// If the post details failed to load previously, reset the state to loading
@@ -77,8 +93,22 @@ class CommentsViewModel(
viewModelScope.launch { commentsRepository.markSeenComments(postId, comments) }
}
internal fun createCommentNodes(details: UIPost, commentState: PostComments?) {
commentsHandler.createListNode(details.comments, commentState) { comment ->
fun upvoteComment(commentId: String) {
if (!isLoggedIn.value) return
viewModelScope.launch {
withContext(ioDispatcher) { authenticatedApi.upvoteComment(commentId) }
}
}
fun unvoteComment(commentId: String) {
if (!isLoggedIn.value) return
viewModelScope.launch {
withContext(ioDispatcher) { authenticatedApi.unvoteComment(commentId) }
}
}
internal fun createCommentNodes(details: UIPost, seenCommentsState: SeenCommentsState) {
commentsHandler.createListNode(details.comments, seenCommentsState) { comment ->
details.userIsAuthor && comment.user == details.submitter
}
}
@@ -87,7 +117,11 @@ class CommentsViewModel(
commentsHandler.updateListNode(shortId, isExpanded)
}
internal fun updateUnreadStatus(commentState: PostComments?) {
commentsHandler.updateUnreadStatus(commentState)
internal fun updateUnreadStatus(seenCommentsState: SeenCommentsState) {
commentsHandler.updateUnreadStatus(seenCommentsState)
}
fun storeCommentText(commentId: String, text: String) {
commentTextHolder.store(commentId, text)
}
}
@@ -0,0 +1,24 @@
/*
* 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.common.comments
import dev.msfjarvis.claw.database.local.PostComments
internal sealed interface SeenCommentsState {
data object Loading : SeenCommentsState
data object NoBaseline : SeenCommentsState
data class BaselineLoaded(val postComments: PostComments) : SeenCommentsState
companion object {
fun from(postComments: PostComments?, hasLoaded: Boolean): SeenCommentsState {
if (!hasLoaded) return Loading
return if (postComments == null) NoBaseline else BaselineLoaded(postComments)
}
}
}
@@ -0,0 +1,24 @@
/*
* 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.common.comments.reply
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import java.util.concurrent.ConcurrentHashMap
@Inject
@SingleIn(AppScope::class)
class CommentTextHolder {
private val texts = ConcurrentHashMap<String, String>()
fun store(commentId: String, text: String) {
texts[commentId] = text
}
fun retrieve(commentId: String): String = texts[commentId].orEmpty()
}
@@ -0,0 +1,181 @@
/*
* 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.common.comments.reply
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.R
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.zacsweers.metrox.viewmodel.metroViewModel
@Composable
fun ReplyScreen(
commentId: String,
contentPadding: PaddingValues,
postId: String,
onReplySubmitted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ReplyViewModel = metroViewModel(key = commentId),
) {
val uiState = viewModel.uiState
val commentText = viewModel.getCommentText(commentId)
LaunchedEffect(uiState.submitSucceeded) {
if (uiState.submitSucceeded) {
onReplySubmitted()
viewModel.clearSubmitSucceeded()
}
}
ReplyScreenContent(
commentText = commentText,
uiState = uiState,
contentPadding = contentPadding,
onEditorChanged = viewModel::updateEditor,
onParentCommentClick = viewModel::showQuoteDialog,
onDismissDialog = viewModel::dismissQuoteDialog,
onQuote = { viewModel.insertQuote(commentText) },
onSubmit = { viewModel.submit(commentId, postId) },
modifier = modifier,
)
}
@Composable
internal fun ReplyScreenContent(
commentText: String,
uiState: ReplyUiState,
contentPadding: PaddingValues,
onEditorChanged: (TextFieldValue) -> Unit,
onParentCommentClick: () -> Unit,
onDismissDialog: () -> Unit,
onQuote: () -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(contentPadding)
.consumeWindowInsets(contentPadding)
.imePadding()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ElevatedCard(onClick = onParentCommentClick, modifier = Modifier.fillMaxWidth()) {
Text(text = commentText, modifier = Modifier.padding(16.dp))
}
TextField(
value = uiState.editor,
onValueChange = onEditorChanged,
label = { Text(stringResource(R.string.reply_editor_label)) },
supportingText = {
uiState.errorMessage?.let { Text(text = it, color = MaterialTheme.colorScheme.error) }
},
enabled = !uiState.isSubmitting,
modifier = Modifier.fillMaxWidth().weight(1f),
)
Button(
onClick = onSubmit,
enabled = !uiState.isSubmitting,
modifier = Modifier.fillMaxWidth(),
) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = null)
Text(text = stringResource(R.string.submit_reply))
}
}
if (uiState.isQuoteDialogOpen) {
AlertDialog(
onDismissRequest = onDismissDialog,
title = { Text(stringResource(R.string.in_reply_to)) },
text = { Text(commentText) },
dismissButton = {
TextButton(onClick = onDismissDialog) { Text(stringResource(R.string.cancel)) }
},
confirmButton = { TextButton(onClick = onQuote) { Text(stringResource(R.string.quote)) } },
)
}
}
@ThemePreviews
@DevicePreviews
@Preview
@Composable
private fun ReplyScreenPreview() {
LobstersTheme {
ReplyScreenContent(
commentText = "Parent comment body",
uiState = ReplyUiState(editor = TextFieldValue("Draft")),
contentPadding = PaddingValues(),
onEditorChanged = {},
onParentCommentClick = {},
onDismissDialog = {},
onQuote = {},
onSubmit = {},
)
}
}
@ThemePreviews
@Composable
private fun ReplyScreenDialogPreview() {
LobstersTheme {
ReplyScreenContent(
commentText = "Parent comment body",
uiState = ReplyUiState(isQuoteDialogOpen = true),
contentPadding = PaddingValues(),
onEditorChanged = {},
onParentCommentClick = {},
onDismissDialog = {},
onQuote = {},
onSubmit = {},
)
}
}
@ThemePreviews
@Composable
private fun ReplyScreenSubmittingPreview() {
LobstersTheme {
ReplyScreenContent(
commentText = "Parent comment body",
uiState = ReplyUiState(editor = TextFieldValue("Draft"), isSubmitting = true),
contentPadding = PaddingValues(),
onEditorChanged = {},
onParentCommentClick = {},
onDismissDialog = {},
onQuote = {},
onSubmit = {},
)
}
}
@@ -0,0 +1,64 @@
/*
* 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.common.comments.reply
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Element
import com.fleeksoft.ksoup.nodes.Node
import com.fleeksoft.ksoup.nodes.TextNode
import com.fleeksoft.ksoup.select.NodeTraversor
import com.fleeksoft.ksoup.select.NodeVisitor
internal fun quoteText(commentText: String): String {
return commentText
.lines()
.joinToString(separator = "\n") { line -> "> ${line.trimEnd()}" }
.plus("\n")
}
private val BLOCK_TAGS =
setOf("p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre")
internal fun plainTextFromHtml(commentText: String): String {
val sb = StringBuilder()
NodeTraversor.traverse(
object : NodeVisitor {
override fun head(node: Node, depth: Int) {
when {
node is TextNode -> sb.append(node.text())
node is Element && node.tagName() == "br" -> sb.append("\n")
node is Element &&
node.tagName() in BLOCK_TAGS &&
sb.isNotEmpty() &&
!sb.endsWith("\n") -> sb.append("\n")
}
}
override fun tail(node: Node, depth: Int) {
if (node is Element && node.tagName() in BLOCK_TAGS && !sb.endsWith("\n")) {
sb.append("\n")
}
}
},
Ksoup.parse(commentText).body(),
)
return sb.toString().trim()
}
internal fun insertQuote(current: TextFieldValue, commentText: String): TextFieldValue {
val quoted = quoteText(commentText)
val range = current.selection
val newText = buildString {
append(current.text.substring(0, range.min))
append(quoted)
append(current.text.substring(range.max))
}
val cursor = range.min + quoted.length
return TextFieldValue(text = newText, selection = TextRange(cursor))
}
@@ -0,0 +1,17 @@
/*
* 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.common.comments.reply
import androidx.compose.ui.text.input.TextFieldValue
internal data class ReplyUiState(
val editor: TextFieldValue = TextFieldValue(),
val isQuoteDialogOpen: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val submitSucceeded: Boolean = false,
)
@@ -0,0 +1,94 @@
/*
* 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.common.comments.reply
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.api.AuthenticatedLobstersApi
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Inject
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class ReplyViewModel(
private val api: AuthenticatedLobstersApi,
private val commentTextHolder: CommentTextHolder,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
fun getCommentText(commentId: String): String = commentTextHolder.retrieve(commentId)
internal var uiState by mutableStateOf(ReplyUiState())
private set
fun updateEditor(value: TextFieldValue) {
uiState = uiState.copy(editor = value, errorMessage = null)
}
fun showQuoteDialog() {
uiState = uiState.copy(isQuoteDialogOpen = true)
}
fun dismissQuoteDialog() {
uiState = uiState.copy(isQuoteDialogOpen = false)
}
fun insertQuote(commentText: String) {
uiState =
uiState.copy(
editor = insertQuote(uiState.editor, commentText),
isQuoteDialogOpen = false,
errorMessage = null,
)
}
fun clearSubmitSucceeded() {
uiState = uiState.copy(submitSucceeded = false)
}
fun submit(commentId: String, postId: String) {
val replyText = uiState.editor.text.trim()
if (replyText.isBlank()) {
uiState = uiState.copy(errorMessage = "Reply cannot be blank")
return
}
uiState = uiState.copy(isSubmitting = true, errorMessage = null)
viewModelScope.launch {
val result = withContext(ioDispatcher) { api.reply(commentId, postId, replyText) }
uiState =
when (result) {
is ApiResult.Success -> uiState.copy(isSubmitting = false, submitSucceeded = true)
is ApiResult.Failure.NetworkFailure ->
uiState.copy(
isSubmitting = false,
errorMessage = result.error.message ?: "Failed to post reply",
)
is ApiResult.Failure.HttpFailure ->
uiState.copy(isSubmitting = false, errorMessage = "Failed to post reply")
is ApiResult.Failure.ApiFailure ->
uiState.copy(isSubmitting = false, errorMessage = "Failed to post reply")
is ApiResult.Failure.UnknownFailure ->
uiState.copy(
isSubmitting = false,
errorMessage = result.error.message ?: "Failed to post reply",
)
}
}
}
}
@@ -0,0 +1,55 @@
/*
* 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.common.login
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToOneOrNull
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
import dev.msfjarvis.claw.core.network.SessionCookieStore
import dev.msfjarvis.claw.database.local.SessionCookieQueries
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class SqlDelightSessionCookieStore(
private val queries: SessionCookieQueries,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
) : SessionCookieStore {
override fun get(): String? {
return queries.get().executeAsOneOrNull()
}
override fun getUsername(): String? {
return queries.getUsername().executeAsOneOrNull()?.username
}
override fun set(cookie: String, username: String) {
queries.upsert(cookie, username)
}
override fun clear() {
queries.deleteAll()
}
override fun isLoggedIn(): Flow<Boolean> {
return queries.getUsername().asFlow().mapToOneOrNull(readDispatcher).map {
it?.username != null
}
}
override fun username(): Flow<String?> {
return queries.getUsername().asFlow().mapToOneOrNull(readDispatcher).map { it?.username }
}
}
@@ -1,61 +0,0 @@
/*
* 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.common.persistence
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesFileSerializer
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoSet
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
@BindingContainer
@ContributesTo(AppScope::class)
object PreferencesStoreModule {
@Provides
@SingleIn(AppScope::class)
fun providePreferencesDataStore(
context: Context,
migrations: Set<DataMigration<Preferences>>,
): DataStore<Preferences> {
return DataStoreFactory.create(
corruptionHandler =
ReplaceFileCorruptionHandler {
Log.d(
"PreferencesStore",
"Preferences data store corruption detected, returning empty preferences.",
it,
)
emptyPreferences()
},
migrations = migrations.toList(),
produceFile = { context.preferencesDataStoreFile("claw_preferences") },
serializer = PreferencesFileSerializer,
)
}
@Provides
@IntoSet
fun firstMigration(): DataMigration<Preferences> =
object : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean = false
override suspend fun migrate(currentData: Preferences): Preferences = currentData
override suspend fun cleanUp() {}
}
}
@@ -17,36 +17,32 @@ import androidx.compose.foundation.layout.ExperimentalFlexBoxApi
import androidx.compose.foundation.layout.FlexBox
import androidx.compose.foundation.layout.FlexDirection
import androidx.compose.foundation.layout.FlexWrap
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Comment
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -68,56 +64,33 @@ fun LobstersCard(
postActions: PostActions,
modifier: Modifier = Modifier,
) {
Box(
Row(
modifier =
modifier
.fillMaxWidth()
.clickable { postActions.viewPost(post.shortId, post.url, post.commentsUrl) }
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
PostDetails(post = post, isRead = { isRead }, modifier = Modifier.weight(1f))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PostDetails(
post = post,
isRead = { isRead },
singleLineTitle = true,
modifier = Modifier.weight(1f),
SaveButton(isSaved = isSaved, onClick = { postActions.toggleSave(post) })
CommentsButton(
commentCount = post.commentCount,
onClick = { postActions.viewComments(post.shortId) },
)
Column(
modifier = Modifier.wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SaveButton(
isSaved = isSaved,
modifier =
Modifier.clickable(role = Role.Button, onClick = { postActions.toggleSave(post) }),
)
HorizontalDivider(modifier = Modifier.width(48.dp))
CommentsButton(
commentCount = post.commentCount,
modifier =
Modifier.clickable(
role = Role.Button,
onClick = { postActions.viewComments(post.shortId) },
),
)
}
}
}
}
@Composable
fun PostDetails(
post: UIPost,
isRead: () -> Boolean,
singleLineTitle: Boolean,
modifier: Modifier = Modifier,
) {
fun PostDetails(post: UIPost, isRead: () -> Boolean, modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
PostTitle(title = post.title, isRead = isRead(), singleLineTitle = singleLineTitle)
PostTitle(title = post.title, isRead = isRead(), maxLines = 2)
TagRow(tags = post.tags.toImmutableList())
Spacer(Modifier.height(4.dp))
Submitter(
@@ -135,8 +108,8 @@ fun PostDetails(
internal fun PostTitle(
title: String,
isRead: Boolean,
singleLineTitle: Boolean,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
) {
Text(
text = title,
@@ -144,7 +117,7 @@ internal fun PostTitle(
style = MaterialTheme.typography.titleMedium,
fontWeight = if (isRead) FontWeight.Normal else FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
maxLines = if (singleLineTitle) 1 else Int.MAX_VALUE,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}
@@ -178,43 +151,61 @@ internal fun Submitter(
}
@Composable
private fun SaveButton(isSaved: Boolean, modifier: Modifier = Modifier) {
private fun SaveButton(isSaved: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Crossfade(targetState = isSaved, label = "save-button") { saved ->
Box(modifier = modifier.minimumInteractiveComponentSize()) {
Button(
modifier = modifier,
onClick = onClick,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Icon(
imageVector = if (saved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = if (saved) "Remove from saved posts" else "Add to saved posts",
modifier = Modifier.align(Alignment.Center).testTag("save_button"),
modifier = Modifier.testTag("save_button"),
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun CommentsButton(commentCount: Int, modifier: Modifier = Modifier) {
Box(modifier = modifier.minimumInteractiveComponentSize()) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Comment,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = stringResource(R.string.open_comments),
modifier = Modifier.align(Alignment.Center).testTag("comments_button"),
)
Box(
modifier =
Modifier.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp)
.requiredSizeIn(16.dp, 16.dp)
.background(MaterialTheme.colorScheme.tertiaryContainer, CircleShape)
.testTag("comment_badge"),
contentAlignment = Alignment.Center,
) {
Text(
text = commentCount.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.testTag("comment_count").padding(2.dp),
private fun CommentsButton(commentCount: Int, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = onClick,
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 6.dp, bottom = 4.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Box(modifier = Modifier.size(width = 42.dp, height = 40.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Comment,
contentDescription = stringResource(R.string.open_comments),
modifier = Modifier.align(Alignment.Center).testTag("comments_button"),
)
Box(
modifier =
Modifier.align(Alignment.TopEnd)
.requiredSizeIn(16.dp, 16.dp)
.background(MaterialTheme.colorScheme.tertiaryContainer, CircleShape)
.testTag("comment_badge"),
contentAlignment = Alignment.Center,
) {
Text(
text = commentCount.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.testTag("comment_count").padding(2.dp),
)
}
}
}
}
@@ -225,10 +216,10 @@ internal fun TagRow(tags: ImmutableList<String>, modifier: Modifier = Modifier)
FlexBox(
modifier = modifier,
config = {
rowGap = 8.dp
columnGap = 8.dp
direction = FlexDirection.Row
wrap = FlexWrap.Wrap
rowGap(8.dp)
columnGap(8.dp)
direction(FlexDirection.Row)
wrap(FlexWrap.Wrap)
},
) {
tags.forEach { tag -> TagText(tag = tag) }
@@ -6,11 +6,6 @@
*/
package dev.msfjarvis.claw.common.tags
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
@@ -18,77 +13,28 @@ import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
import dev.msfjarvis.claw.database.local.TagBlocksQueries
import dev.msfjarvis.claw.model.TagBlock
import dev.zacsweers.metro.Inject
import io.sentry.Sentry
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@Inject
class TagBlockRepository(
private val tagBlocksQueries: TagBlocksQueries,
private val preferences: DataStore<Preferences>,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
) {
private val legacyTagsKey = stringSetPreferencesKey("tags")
private val timedTagsKey = stringPreferencesKey("tags_with_expiration")
private var migrationCompleted = false
private suspend fun migrateFromDataStore() {
if (migrationCompleted) return
val prefs = preferences.data.first()
val timedTags = prefs[timedTagsKey]
val legacyTags = prefs[legacyTagsKey]
if (timedTags == null && legacyTags == null) {
migrationCompleted = true
Sentry.metrics().count("tag_sqlite_migration_not_required")
return
} else {
Sentry.metrics().count("tag_sqlite_migration_required")
}
withContext(writeDispatcher) {
if (timedTags != null) {
val tagMap = Json.decodeFromString<Map<String, Long?>>(timedTags)
tagMap.forEach { (tag, expiration) -> tagBlocksQueries.insertOrReplace(tag, expiration) }
} else {
legacyTags?.forEach { tag -> tagBlocksQueries.insertOrReplace(tag, null) }
}
// Clean up DataStore after successful migration
preferences.edit { prefs ->
prefs.remove(timedTagsKey)
prefs.remove(legacyTagsKey)
}
migrationCompleted = true
}
}
fun getSavedTags(): Flow<Set<String>> {
val now = System.currentTimeMillis()
return tagBlocksQueries
.selectActiveTags(now)
.asFlow()
.mapToList(readDispatcher)
.onStart { migrateFromDataStore() }
.map { it.toSet() }
return tagBlocksQueries.selectActiveTags(now).asFlow().mapToList(readDispatcher).map {
it.toSet()
}
}
fun getTagBlocks(): Flow<List<TagBlock>> {
return tagBlocksQueries
.selectAll()
.asFlow()
.mapToList(readDispatcher)
.onStart { migrateFromDataStore() }
.map { blocks -> blocks.map { TagBlock(it.tag, it.expiration_millis) } }
return tagBlocksQueries.selectAll().asFlow().mapToList(readDispatcher).map { blocks ->
blocks.map { TagBlock(it.tag, it.expiration_millis) }
}
}
suspend fun saveTagBlock(tag: String, expirationMillis: Long?) {
@@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Inject
@ViewModelKey(TagFilterViewModel::class)
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class TagFilterViewModel(
private val api: LobstersApi,
@@ -54,7 +54,7 @@ class TagFilterViewModel(
runSuspendCatching<ImmutableList<Tag>> {
withContext(ioDispatcher) {
when (val result = api.getTags()) {
is Success -> result.value.toImmutableList()
is Success -> result.value.tags.toImmutableList()
is Failure.NetworkFailure -> throw result.error
is Failure.UnknownFailure -> throw result.error
is Failure.HttpFailure -> throw result.toError()
@@ -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 = {
@@ -142,10 +144,10 @@ fun TagList(
FlexBox(
modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
config = {
columnGap = 8.dp
rowGap = 8.dp
direction = FlexDirection.Row
wrap = FlexWrap.Wrap
columnGap(8.dp)
rowGap(8.dp)
direction(FlexDirection.Row)
wrap(FlexWrap.Wrap)
},
) {
allTags
@@ -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')}"
}
@@ -30,7 +30,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.R
@@ -73,9 +72,8 @@ fun FloatingNavigationBar(
}
}
@Preview
@Composable
private fun FloatingNavigationBarPreview() {
internal fun FloatingNavigationBarPreviewContent() {
FloatingNavigationBar {
NavigationBarItem(
selected = true,
@@ -23,11 +23,11 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.core.net.toUri
import be.digitalia.compose.htmlconverter.HtmlStyle
import be.digitalia.compose.htmlconverter.htmlToAnnotatedString
import com.fleeksoft.ksoup.Ksoup
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.common.BuildConfig
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
import org.jsoup.Jsoup
@Composable
internal fun ThemedRichText(text: String, modifier: Modifier = Modifier) {
@@ -64,7 +64,7 @@ internal fun ThemedRichText(text: String, modifier: Modifier = Modifier) {
}
internal fun preprocessHtml(html: String): String {
val document = Jsoup.parse(html)
val document = Ksoup.parse(html)
document.select("li").forEach { li ->
li.select("p").forEach { p ->
p.childNodes().forEach { child -> p.before(child) }
@@ -109,9 +109,8 @@ private fun rewriteLobstersLinksToDeepLinks(annotatedString: AnnotatedString): A
.toAnnotatedString()
}
private val lobstersUri = LobstersApi.BASE_URL.toUri()
private fun rewriteUrlIfLobstersPost(url: String): String {
val lobstersUri = LobstersApi.BASE_URL.toUri()
return try {
val uri = url.toUri()
if (
@@ -93,7 +93,7 @@ private fun UserProfileInternal(
.verticalScroll(scrollState),
) {
NetworkImage(
url = "https://lobste.rs/${user.avatarUrl}",
url = user.avatarUrl,
placeholder = Icons.Filled.AccountCircle,
contentDescription = stringResource(R.string.avatar_of, user.username),
modifier = Modifier.requiredSize(120.dp).clip(CircleShape),
@@ -31,7 +31,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@Inject
@ViewModelKey(UserProfileViewModel::class)
@ViewModelKey
@ContributesIntoMap(AppScope::class)
class UserProfileViewModel(
private val api: LobstersApi,
+5
View File
@@ -23,4 +23,9 @@
<string name="copy_stacktrace">Copy stacktrace</string>
<string name="stacktrace">Stacktrace</string>
<string name="search">Search</string>
<string name="reply_editor_label">Write a reply</string>
<string name="submit_reply">Submit reply</string>
<string name="in_reply_to">In reply to</string>
<string name="quote">Quote</string>
<string name="cancel">Cancel</string>
</resources>
@@ -0,0 +1,107 @@
/*
* 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.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(CommentEntryPreviewParameterProvider::class)
params: CommentEntryPreviewParameters
) {
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 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,
)
)
}
}
@@ -0,0 +1,33 @@
/*
* 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.common.comments.reply
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
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
@PreviewTest
@DevicePreviews
@ThemePreviews
@Composable
private fun ReplyScreenPreviewScreenshot() {
LobstersTheme {
ReplyScreenContent(
commentText = "Parent comment body",
uiState = ReplyUiState(),
contentPadding = PaddingValues(),
onEditorChanged = {},
onParentCommentClick = {},
onDismissDialog = {},
onQuote = {},
onSubmit = {},
)
}
}
@@ -0,0 +1,18 @@
/*
* 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.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest
@PreviewTest
@Preview
@Composable
private fun FloatingNavigationBarPreview() {
FloatingNavigationBarPreviewContent()
}

Some files were not shown because too many files have changed in this diff Show More