Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55678d3914 | ||
|
|
2661b82950 | ||
|
|
4bbf092c2d | ||
|
|
7cbf87080d | ||
|
|
784bda4105 | ||
|
|
8adcef03d6 | ||
|
|
7efa5600d0 | ||
|
|
cb75ef0673 | ||
|
|
ae8db6d851 | ||
|
|
265a00e9b1 | ||
|
|
94aa52b669 | ||
|
|
ca2dbd758c | ||
|
|
fa22dbe4cf | ||
|
|
481efd24ab | ||
|
|
19346101c6 | ||
|
|
b19a48fa09 | ||
|
|
5457c204cf | ||
|
|
05e0664d14 | ||
|
|
e91338b31e | ||
|
|
f77221aeea | ||
|
|
8ee7d5dc28 | ||
|
|
76cb3376b7 | ||
|
|
9ba9791de8 | ||
|
|
43408c2bec | ||
|
|
7ec580d9c6 | ||
|
|
18b33e550a | ||
|
|
6d932c84d5 | ||
|
|
03e0a13e1d | ||
|
|
5992a5fa15 | ||
|
|
6b84d5277b | ||
|
|
f41c36c24c | ||
|
|
9b4449c890 | ||
|
|
4904c76220 | ||
|
|
c5a8dea7d2 | ||
|
|
8930c0c5dc | ||
|
|
5dc4afbf36 | ||
|
|
c3ec92582f | ||
|
|
b8ee16be84 | ||
|
|
c32ed849d2 | ||
|
|
997b37ccb0 | ||
|
|
976ed3b31a | ||
|
|
75863cdf94 | ||
|
|
a3687d0b4c | ||
|
|
2b571be006 | ||
|
|
820744409e | ||
|
|
1b6ae68e0d | ||
|
|
8c67a874a5 | ||
|
|
8296f58ad9 | ||
|
|
1cd40a1383 | ||
|
|
dce9dc33fe | ||
|
|
12c98aa2c4 | ||
|
|
bb05c46e57 | ||
|
|
12f075a23d | ||
|
|
5065889e27 | ||
|
|
39ea7c9c2e | ||
|
|
6f972a8099 | ||
|
|
4d8357c774 | ||
|
|
c92be255d2 | ||
|
|
007c303272 | ||
|
|
b922dfafee | ||
|
|
9fab85419c | ||
|
|
09bc8f8ff8 | ||
|
|
e248796c49 | ||
|
|
9297799549 | ||
|
|
26fdbe0bed | ||
|
|
4faf965851 | ||
|
|
62016180b9 | ||
|
|
875b41d0bf | ||
|
|
052cce6a05 | ||
|
|
54697ce4ca | ||
|
|
378f140d1a | ||
|
|
00f0e27a0b | ||
|
|
e21759e6f1 | ||
|
|
0d88a7cf78 | ||
|
|
87fe06a419 | ||
|
|
fcdc9db79c | ||
|
|
943398bb67 | ||
|
|
fc67bda7cb | ||
|
|
62a71a309b | ||
|
|
3b0a0e3289 | ||
|
|
1274897eac | ||
|
|
017f970f61 | ||
|
|
bda3c4c26c | ||
|
|
c14f529f5e | ||
|
|
0ff78e5b00 | ||
|
|
de966de4ec | ||
|
|
8a1a64d173 | ||
|
|
d1e5df8bc4 | ||
|
|
d80ccfe284 | ||
|
|
c2cf007b34 | ||
|
|
9cd9d3830a | ||
|
|
38052b359e | ||
|
|
96151df57f | ||
|
|
a141573442 | ||
|
|
ac537183fb | ||
|
|
9faac26d57 | ||
|
|
03baf5edc1 | ||
|
|
2b8a245481 | ||
|
|
2d88ebafa3 | ||
|
|
a1f0c79172 | ||
|
|
039c084967 | ||
|
|
a1ae8af8a8 | ||
|
|
ad269f9edc | ||
|
|
7bf21ba23e | ||
|
|
12226b48e8 | ||
|
|
1d3ba7c0de | ||
|
|
6e779cd438 | ||
|
|
21ba2c3869 | ||
|
|
61d8c4e4e0 | ||
|
|
ccdc75f7a3 | ||
|
|
f1ae3c1a44 | ||
|
|
aa32599a59 | ||
|
|
a58edda23e | ||
|
|
a833820be3 | ||
|
|
16cc3097b7 | ||
|
|
49318b2766 | ||
|
|
c9a22df572 | ||
|
|
13d368b86a | ||
|
|
4c14890170 | ||
|
|
43d1b51901 | ||
|
|
088fcf5a62 | ||
|
|
e0bb04ce4c | ||
|
|
b621fb0480 | ||
|
|
def7d1a727 | ||
|
|
24abd4171a | ||
|
|
31be02c521 | ||
|
|
79f2b5d01c | ||
|
|
ae047d8a6d | ||
|
|
864b023302 | ||
|
|
502eb751eb | ||
|
|
210bc89346 | ||
|
|
9ecb0fa704 | ||
|
|
635e33233b | ||
|
|
2cf53422bf | ||
|
|
80980af1c9 | ||
|
|
3b5c4b3ddb | ||
|
|
84e36496d3 | ||
|
|
3f714f41c3 | ||
|
|
e20c90297a | ||
|
|
0ac8c91789 | ||
|
|
d74b0c3f40 | ||
|
|
7040518fc7 | ||
|
|
e27a3582f8 | ||
|
|
f4b3f936ae | ||
|
|
199ddc9ca8 | ||
|
|
792b8b343b | ||
|
|
b10c87c33d | ||
|
|
b6ed8e914e | ||
|
|
3c6145c1f3 | ||
|
|
e1ca964185 | ||
|
|
2e84ada707 | ||
|
|
758f88e02f | ||
|
|
eef58ab267 | ||
|
|
9d34cf6c2e | ||
|
|
bfb402d4ae | ||
|
|
66c4dc78f4 | ||
|
|
34e4836f2d | ||
|
|
c77d10942f | ||
|
|
9c6c5e82cb | ||
|
|
dac23176ff | ||
|
|
5c8174879f | ||
|
|
30a5bcc687 | ||
|
|
30293d29f6 | ||
|
|
0269a284ef | ||
|
|
19103c19b9 | ||
|
|
96f2c78a4e | ||
|
|
4e44758c39 | ||
|
|
a54940f47a | ||
|
|
e61bb6bcd8 | ||
|
|
25909e9949 | ||
|
|
6f5eecb694 | ||
|
|
92aae743ab | ||
|
|
bd1d8dd8a0 | ||
|
|
f3fb95dc5f | ||
|
|
dc98d29fde | ||
|
|
4234fda686 | ||
|
|
f9122cf0b6 | ||
|
|
4c6c7f4995 | ||
|
|
ba59ba9015 | ||
|
|
4d4bbb440e | ||
|
|
f0ed3871df | ||
|
|
d5170b201f | ||
|
|
77c635b824 | ||
|
|
83db9bb8a8 | ||
|
|
6512567990 | ||
|
|
f244d35f74 | ||
|
|
4b948cb7f4 | ||
|
|
54455cc4c4 | ||
|
|
a4828040a8 | ||
|
|
349f1fbc27 | ||
|
|
6ac339f56b | ||
|
|
e8cc694950 | ||
|
|
e3615795c0 | ||
|
|
43a2c2f4b3 | ||
|
|
a9f0569944 | ||
|
|
5901b22fef | ||
|
|
36213ad0a9 | ||
|
|
a10fe200e6 | ||
|
|
f6bfca6bbb | ||
|
|
94b680192a | ||
|
|
a8dc8d3991 | ||
|
|
46e55a5c26 | ||
|
|
820e422cfe | ||
|
|
cc4c022e4e | ||
|
|
70e7c305d0 | ||
|
|
e2242fce2e |
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 |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,3 +248,4 @@ distribution/
|
||||
screenshots/
|
||||
claw-export.json
|
||||
.kotlin/
|
||||
.worktrees/
|
||||
|
||||
+56
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
|
||||
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile, LineNumberTable
|
||||
|
||||
# Keep Glance ActionCallback classes for reflection
|
||||
-keep class * implements androidx.glance.action.ActionCallback {
|
||||
public <init>();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile, LineNumberTable
|
||||
@@ -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>
|
||||
|
||||
+3306
-1921
File diff suppressed because it is too large
Load Diff
+3306
-1921
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
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+8
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -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,
|
||||
|
||||
+35
-10
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 Gemini’s 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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1431
File diff suppressed because it is too large
Load Diff
+169
@@ -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="{"O":"Off-topic","A":"Already Posted","B":"Broken Link","S":"Spam","":"Cancel"}">
|
||||
<meta name="comment-flags" content="{"O":"Off-topic","M":"Me-too","T":"Troll","U":"Unkind","S":"Spam","":"Cancel"}">
|
||||
|
||||
<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 +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
@@ -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="{"O":"Off-topic","A":"Already Posted","B":"Broken Link","S":"Spam","":"Cancel"}">
|
||||
<meta name="comment-flags" content="{"O":"Off-topic","M":"Me-too","T":"Troll","U":"Unkind","S":"Spam","":"Cancel"}">
|
||||
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:site_name" content="Lobsters">
|
||||
<meta property="og:title" content="Ranking comments by sum of replies' 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' 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' 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's comments page because their "root comment" has a low score.
|
||||
|
||||
An example, as of 2022-07-22 17:00 UTC, is <https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt>:
|
||||
* 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">☶</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 > 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>>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
@@ -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="{"O":"Off-topic","A":"Already Posted","B":"Broken Link","S":"Spam","":"Cancel"}">
|
||||
<meta name="comment-flags" content="{"O":"Off-topic","M":"Me-too","T":"Troll","U":"Unkind","S":"Spam","":"Cancel"}">
|
||||
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:site_name" content="Lobsters">
|
||||
<meta property="og:title" content="Ranking comments by sum of replies' 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' 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' 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's comments page because their "root comment" has a low score.
|
||||
|
||||
An example, as of 2022-07-22 17:00 UTC, is <https://lobste.rs/s/ekvqcf/random_wallpaper_with_just_bash_systemd#c_vop9bt>:
|
||||
* 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">☶</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 > 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>>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
@@ -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>
|
||||
+1202
-6
File diff suppressed because one or more lines are too long
Vendored
+888
@@ -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="{"O":"Off-topic","A":"Already Posted","B":"Broken Link","S":"Spam","":"Cancel"}">
|
||||
<meta name="comment-flags" content="{"O":"Off-topic","M":"Me-too","T":"Troll","U":"Unkind","S":"Spam","":"Cancel"}">
|
||||
|
||||
<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's "Science"" id="science" href="/t/science">science</a>
|
||||
<span class="">It's "Science"</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't also tag with `ai`." id="vibecoding" href="/t/vibecoding">vibecoding</a>
|
||||
<span class="">Using AI/LLM, coding tools. Don'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
@@ -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
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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 }
|
||||
}
|
||||
}
|
||||
-61
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+107
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
@@ -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
Reference in New Issue
Block a user