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 | ||
|
|
153867426a | ||
|
|
68c3e0f58d | ||
|
|
7c9b93fc3a | ||
|
|
c6cf566b13 | ||
|
|
51c62bb441 | ||
|
|
389de2f9af | ||
|
|
19a0f67d6d | ||
|
|
5bd3c3fbb9 | ||
|
|
12d9b1e720 | ||
|
|
944200cb42 | ||
|
|
4fa947ea18 | ||
|
|
5f9ee4effb | ||
|
|
956afbd0c2 | ||
|
|
de2366cf94 | ||
|
|
7dde15b65d | ||
|
|
173b5ef242 | ||
|
|
89110596d0 | ||
|
|
fa15fc9fb2 | ||
|
|
dac3d505d0 | ||
|
|
2dbd8b0cb0 | ||
|
|
c3d5a4aeb2 | ||
|
|
1636adec9d | ||
|
|
55c3f3165a | ||
|
|
11f232ba44 | ||
|
|
c750c492ae | ||
|
|
0ee2fd5963 | ||
|
|
6529567817 | ||
|
|
219b53c23d | ||
|
|
1c2635e2fd | ||
|
|
960e226f8f | ||
|
|
e1e691ff4a | ||
|
|
3a2779f6b6 | ||
|
|
9ba11422ab | ||
|
|
72219b7187 | ||
|
|
d022ebf475 | ||
|
|
ec031da893 | ||
|
|
e081f428e7 | ||
|
|
a3d8d5b4d3 | ||
|
|
37523588e1 | ||
|
|
e5d15109ce | ||
|
|
a9099fe25b | ||
|
|
9091e0b096 | ||
|
|
3089d8d4f6 | ||
|
|
1e2008115b | ||
|
|
62f51c7e91 | ||
|
|
54fffdbd72 | ||
|
|
a15f809d86 | ||
|
|
0da0cb56bb | ||
|
|
a1b3e2686b | ||
|
|
650285f2b9 | ||
|
|
9857883b52 | ||
|
|
cc4f102efc | ||
|
|
7b4d13071d | ||
|
|
e1a0738afb | ||
|
|
fcc29213d0 | ||
|
|
bbfc52dfe2 | ||
|
|
3db67ddbb0 | ||
|
|
315df3032b | ||
|
|
25f7189f68 | ||
|
|
393af79ded | ||
|
|
00b9a7a0db | ||
|
|
62f9fb8b37 | ||
|
|
3f04bfbbca | ||
|
|
573ca7a2f9 | ||
|
|
69e36ca09a | ||
|
|
5bc01eab4d | ||
|
|
48b641d3b8 | ||
|
|
a6517120b1 | ||
|
|
78fecb7192 | ||
|
|
c66b110525 | ||
|
|
e90ea6a6d4 | ||
|
|
ae580612dd | ||
|
|
3736086445 | ||
|
|
0f817ff7f9 | ||
|
|
bf20553e15 | ||
|
|
850ee0bec8 | ||
|
|
fd073e7210 | ||
|
|
8ba3a9c7fa | ||
|
|
9ec30ecf81 | ||
|
|
a363b992a7 | ||
|
|
b604c60cc4 | ||
|
|
4b80be25de | ||
|
|
da65b056ee | ||
|
|
769fb6ce51 | ||
|
|
35a59bc53c | ||
|
|
a4d3af8049 | ||
|
|
eadd898853 | ||
|
|
da72851ecc | ||
|
|
006a2593c6 | ||
|
|
e44f9eacf0 | ||
|
|
0d442880f0 | ||
|
|
6a7d2606f9 | ||
|
|
01ac01a051 | ||
|
|
88dc15a7e7 | ||
|
|
e55d9b5007 | ||
|
|
ef90a8e100 | ||
|
|
3e8bd94cf0 | ||
|
|
e2b3671f4c | ||
|
|
64c486d81c | ||
|
|
6e9b4ccdae | ||
|
|
04ab7d1b60 | ||
|
|
419e93f940 | ||
|
|
a6862d4e93 | ||
|
|
8f383c895d | ||
|
|
b4ba657185 | ||
|
|
80bdcf0ed9 | ||
|
|
da57278fea | ||
|
|
4dd9630e92 | ||
|
|
d4118eaa67 | ||
|
|
ed97836be6 | ||
|
|
e0ae2392f3 | ||
|
|
fd041b4344 | ||
|
|
f16452c360 | ||
|
|
1294d6fc3c | ||
|
|
2fe7004b8f | ||
|
|
f7c8f0f979 | ||
|
|
991e7ecac4 | ||
|
|
c6d1a79747 | ||
|
|
51eb6be86c | ||
|
|
7ee0071da6 | ||
|
|
b29785a5fc | ||
|
|
a44e74788d | ||
|
|
f1b8f38aca | ||
|
|
661033c287 | ||
|
|
23e24c465b | ||
|
|
2817c41790 | ||
|
|
ee268e35fa | ||
|
|
18235ed55d | ||
|
|
cfb4e34db0 | ||
|
|
35316450cf | ||
|
|
f4ae71b3c8 | ||
|
|
f94c466003 | ||
|
|
1c69a4bcbe | ||
|
|
6a5e8daecf | ||
|
|
1ab313ed70 | ||
|
|
9dfffa5219 | ||
|
|
e07bd441a8 | ||
|
|
ff71aeb4ef | ||
|
|
f76bb0eb08 | ||
|
|
589ea7585e | ||
|
|
cb6e24296d | ||
|
|
287eb04913 | ||
|
|
ccafc96c78 | ||
|
|
02ef739624 | ||
|
|
37d364b5fa | ||
|
|
dd8b62f7b8 | ||
|
|
db0412c308 | ||
|
|
23b3afd0a9 | ||
|
|
33dc57a928 | ||
|
|
1540208966 | ||
|
|
b29065e27c | ||
|
|
14f5528157 | ||
|
|
50720a06e4 | ||
|
|
9213edd132 | ||
|
|
4c0089676a | ||
|
|
71e54aac31 | ||
|
|
e648bde12a | ||
|
|
27e670a1f1 | ||
|
|
4df64bb946 | ||
|
|
7efb206c68 | ||
|
|
625064f1fa | ||
|
|
f16b9a1b54 | ||
|
|
4137132b40 | ||
|
|
8c7aa23532 | ||
|
|
2b7c3e790a | ||
|
|
b7721602ff | ||
|
|
15a04a7684 | ||
|
|
3ffe7ca7a8 | ||
|
|
561dcb2bb9 | ||
|
|
1e3ca5b822 | ||
|
|
216a6eff3a | ||
|
|
3390031120 | ||
|
|
f079421a00 | ||
|
|
aa8756065c | ||
|
|
6004d31af4 | ||
|
|
f3a5956216 | ||
|
|
9d38a02819 | ||
|
|
7f608fd536 | ||
|
|
b242333e8a | ||
|
|
663271d980 | ||
|
|
67dd99ae42 | ||
|
|
cd0d24ecc1 | ||
|
|
6b96282a70 | ||
|
|
8f6f5c658b | ||
|
|
dbe2868584 | ||
|
|
ab872b6eaa | ||
|
|
c37621ed9a | ||
|
|
16f887792d | ||
|
|
7c39744ddd | ||
|
|
b47be69808 | ||
|
|
7f0acae2da | ||
|
|
b3373473a7 | ||
|
|
7c7c217f8b | ||
|
|
252e5bc037 | ||
|
|
12c2708b50 | ||
|
|
5be3dee120 | ||
|
|
2110c24f0c | ||
|
|
ecd8248c8a | ||
|
|
e6eeaafda1 | ||
|
|
c9e5b1a714 | ||
|
|
14f09f9009 | ||
|
|
a17b27d078 | ||
|
|
3d978884ea | ||
|
|
271afb980f | ||
|
|
8d384f1f2d | ||
|
|
e4fb99ade8 | ||
|
|
1af36bea91 | ||
|
|
6580afd90e | ||
|
|
476e25f7d9 | ||
|
|
0f4850eed3 | ||
|
|
a66903e4cf | ||
|
|
e4309a6741 | ||
|
|
87b010dea6 | ||
|
|
3b98ee3ee0 | ||
|
|
da0e5d1d97 | ||
|
|
b0a469036f | ||
|
|
b4d2dbb8dd | ||
|
|
a8b94079ca | ||
|
|
a72b7ce9d1 | ||
|
|
d1a3c098a7 | ||
|
|
40e867585d | ||
|
|
65ec8e1956 | ||
|
|
fe7fb7197a | ||
|
|
5540d924c5 | ||
|
|
6d0953742b | ||
|
|
a6c47d0dee | ||
|
|
0394d91069 | ||
|
|
82273c3e42 | ||
|
|
766decbe55 | ||
|
|
4a3bca55c4 | ||
|
|
4a7921d187 | ||
|
|
7f5dbf79e3 | ||
|
|
a725db0483 | ||
|
|
9c05e9b2e2 | ||
|
|
2fee56d566 | ||
|
|
6f29a35af2 | ||
|
|
bc029bc60e | ||
|
|
f09f89dc6c | ||
|
|
de1d21ec4f | ||
|
|
39c12f2173 | ||
|
|
3ab2eb8b66 | ||
|
|
31b54e1443 | ||
|
|
b48bac29fc | ||
|
|
44d240c65e | ||
|
|
1d7d166704 | ||
|
|
fc463a3c23 | ||
|
|
55b458df49 | ||
|
|
76ad5f77b6 | ||
|
|
a1af9974be | ||
|
|
e3d433d62f | ||
|
|
7a6878cdd1 | ||
|
|
4d3fe5509f | ||
|
|
be86dbb25b | ||
|
|
8adbd66e9d | ||
|
|
3ddfd77771 | ||
|
|
fd910f40fa | ||
|
|
ca8a16219d | ||
|
|
89fe85b229 | ||
|
|
7cdff634b7 | ||
|
|
0c0fefec9b | ||
|
|
28660b8940 | ||
|
|
5c311097cd | ||
|
|
4cbeffadf5 | ||
|
|
dbddbfc5bc | ||
|
|
8d603d6fbd | ||
|
|
38770b6e86 | ||
|
|
43e3eaf6e3 |
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 432 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 435 KiB |
@@ -54,10 +54,13 @@
|
||||
"allowedVersions": "!/.*-1-8$/",
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"/^androidx.glance/",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"allowedVersions": "<=1.0.0",
|
||||
"matchDepNames": [
|
||||
"msfjarvis/compose-lobsters"
|
||||
],
|
||||
"enabled": false,
|
||||
},
|
||||
],
|
||||
"customManagers": [
|
||||
|
||||
@@ -25,21 +25,35 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: ${{ inputs.fetch-depth }}
|
||||
token: ${{ inputs.token }}
|
||||
lfs: true
|
||||
persist-credentials: ${{ inputs.persist-credentials }}
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 23
|
||||
java-version: 25
|
||||
|
||||
- name: Accept Android SDK licenses
|
||||
shell: bash
|
||||
run: |
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
|
||||
|
||||
- name: Set global Gradle properties
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.gradle/
|
||||
echo "android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect" >> ~/.gradle/gradle.properties
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
with:
|
||||
add-job-summary: always
|
||||
cache-read-only: ${{ inputs.cache-read-only }}
|
||||
|
||||
@@ -16,18 +16,17 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: "github.event.pull_request.draft == false"
|
||||
runs-on: macos-15
|
||||
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
|
||||
@@ -39,24 +38,43 @@ jobs:
|
||||
|
||||
- name: (Fail-only) Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Test report
|
||||
path: '**/build/reports/tests/**'
|
||||
|
||||
dependency-review:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-26
|
||||
if: "github.event_name == 'push' && github.event.ref != 'refs/heads/main'"
|
||||
needs:
|
||||
- check
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
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 }}
|
||||
|
||||
release-app:
|
||||
runs-on: macos-26
|
||||
needs:
|
||||
- check
|
||||
steps:
|
||||
- name: Setup build environment
|
||||
uses: msfjarvis/compose-lobsters/.github/reusable-workflows/setup-gradle@92aae743ab8e1c98741128f1bfda3715750f5cf6 # main
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Build release app
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
./gradlew --no-configuration-cache --stacktrace assembleRelease collectReleaseBundle
|
||||
./scripts/signing-cleanup.sh
|
||||
env:
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
|
||||
@@ -13,10 +13,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
publish-google-play-release:
|
||||
runs-on: macos-15
|
||||
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
|
||||
@@ -72,14 +72,43 @@ jobs:
|
||||
git add .
|
||||
git commit -m 'feat(release): bump version'
|
||||
|
||||
- name: Calculate VCS info for Sentry
|
||||
shell: bash
|
||||
run: |
|
||||
# Get HEAD commit SHA
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
|
||||
|
||||
# Get parent info (format: commit parent1 [parent2...])
|
||||
PARENTS=$(git rev-list --parents -n 1 HEAD)
|
||||
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
|
||||
|
||||
if [ "$PARENT_COUNT" -ge 3 ]; then
|
||||
# Merge commit (commit + 2+ parents): use first parent
|
||||
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
|
||||
else
|
||||
# Normal commit: use last release
|
||||
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
|
||||
else
|
||||
BASE_SHA="${HEAD_SHA}"
|
||||
fi
|
||||
fi
|
||||
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build release assets
|
||||
shell: bash
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
|
||||
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
./gradlew --no-configuration-cache assembleRelease collectReleaseApks collectReleaseBundle -PenableSentry
|
||||
./gradlew --no-configuration-cache assembleRelease uploadSentryProguardMappingsRelease collectReleaseApks collectReleaseBundle -PenableSentry
|
||||
|
||||
- name: Clean secrets
|
||||
run: scripts/signing-cleanup.sh
|
||||
@@ -93,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
|
||||
@@ -115,8 +144,31 @@ jobs:
|
||||
# Push the tag to GitHub
|
||||
git push origin "v${VERSION}"
|
||||
|
||||
# Create GitHub release notes with footer
|
||||
RELEASE_NOTES=$(mktemp)
|
||||
{
|
||||
cat distribution/whatsnew/whatsnew-en-GB
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo '<a href="https://play.google.com/store/apps/details?id=dev.msfjarvis.claw.android">'
|
||||
echo ' <img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"'
|
||||
echo ' alt="Get it on Google Play"'
|
||||
echo ' height="80" />'
|
||||
echo '</a>'
|
||||
echo ""
|
||||
echo '<a href="https://android.izzysoft.de/repo/apk/dev.msfjarvis.claw.android">'
|
||||
echo ' <img src="./.github/izzy-badge.webp"'
|
||||
echo ' alt="Get it on IzzyOnDroid"'
|
||||
echo ' height="65" />'
|
||||
echo '</a>'
|
||||
} > "$RELEASE_NOTES"
|
||||
|
||||
# Create a GitHub release
|
||||
gh release create "v${VERSION}" --notes-file distribution/whatsnew/whatsnew-en-GB --title "v${VERSION}" ./android/apk/*.apk ./android/bundle/*.aab
|
||||
gh release create "v${VERSION}" --notes-file "$RELEASE_NOTES" --title "v${VERSION}" ./android/apk/*.apk ./android/bundle/*.aab
|
||||
|
||||
# Clean up temp file
|
||||
rm "$RELEASE_NOTES"
|
||||
|
||||
# Start the next development iteration
|
||||
./gradlew -q --no-configuration-cache bumpSnapshot
|
||||
|
||||
@@ -12,9 +12,10 @@ 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
|
||||
|
||||
- name: Configure Tramline
|
||||
id: tramline
|
||||
@@ -22,21 +23,50 @@ jobs:
|
||||
with:
|
||||
input: ${{ github.event.inputs.tramline-input }}
|
||||
|
||||
- name: Calculate VCS info for Sentry
|
||||
shell: bash
|
||||
run: |
|
||||
# Get HEAD commit SHA
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
echo "SENTRY_VCS_HEAD_SHA=${HEAD_SHA}" >> $GITHUB_ENV
|
||||
|
||||
# Get parent info (format: commit parent1 [parent2...])
|
||||
PARENTS=$(git rev-list --parents -n 1 HEAD)
|
||||
PARENT_COUNT=$(echo "$PARENTS" | wc -w)
|
||||
|
||||
if [ "$PARENT_COUNT" -ge 3 ]; then
|
||||
# Merge commit (commit + 2+ parents): use first parent
|
||||
BASE_SHA=$(echo "$PARENTS" | awk '{print $2}')
|
||||
else
|
||||
# Normal commit: use last release
|
||||
LAST_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName' || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
BASE_SHA=$(git rev-parse "${LAST_TAG}^{commit}")
|
||||
else
|
||||
BASE_SHA="${HEAD_SHA}"
|
||||
fi
|
||||
fi
|
||||
echo "SENTRY_VCS_BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build release app
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
./gradlew --no-configuration-cache --stacktrace assembleRelease collectReleaseBundle -PenableSentry
|
||||
./gradlew --no-configuration-cache --stacktrace assembleRelease uploadSentryProguardMappingsRelease collectReleaseBundle -PenableSentry
|
||||
./scripts/signing-cleanup.sh
|
||||
env:
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_VCS_HEAD_SHA: ${{ env.SENTRY_VCS_HEAD_SHA }}
|
||||
SENTRY_VCS_BASE_SHA: ${{ env.SENTRY_VCS_BASE_SHA }}
|
||||
ORG_GRADLE_PROJECT_VERSION_NAME: ${{ steps.tramline.outputs.version_name }}
|
||||
ORG_GRADLE_PROJECT_VERSION_CODE: ${{ steps.tramline.outputs.version_code }}
|
||||
|
||||
- name: Upload app bundle
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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/
|
||||
|
||||
+128
-5
@@ -7,6 +7,120 @@ 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
|
||||
|
||||
- Improve comment scrolling performance
|
||||
- Ensure saved/read state for posts updates properly in UI
|
||||
- All strings in the app are now translatable, open to volunteer contributions.
|
||||
|
||||
## [1.61.0] - 2026-02-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Collapsed comments state are now persisted across navigation events
|
||||
|
||||
### Changed
|
||||
|
||||
- Tags use a more muted background color to avoid being distracting.
|
||||
- Tag blocks can now be time-bound
|
||||
|
||||
## [1.60.0] - 2026-02-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Rare crash in widget handling
|
||||
- Line-breaking behavior in comments now packs text densely
|
||||
- Tag filtering now applies to widgets as well
|
||||
|
||||
### Changed
|
||||
|
||||
- Links to other posts now open in the app itself
|
||||
- Widget handles bad network scenarios better
|
||||
|
||||
## [1.59.0] - 2026-01-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Crashes from process death
|
||||
- Crashes in widget handling
|
||||
- Android Auto Backup works again
|
||||
|
||||
### Changed
|
||||
|
||||
- Added a tag filtering option to settings
|
||||
- Comment count badge has been restored
|
||||
- Navigation behavior has been improved to avoid too-deep hierarchies
|
||||
- Widget clicks are handled reliably
|
||||
- Widget UI has been touched up and a preview added for Android 16+
|
||||
- User profile page is now scrollable
|
||||
|
||||
## [1.58.0] - 2025-12-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Avoid crash from trying to import invalid backups
|
||||
- Avoid crash when posts move between pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Widget update is more reliable
|
||||
- Increased network timeouts
|
||||
- Better network retries and rate limit avoidance
|
||||
- Temporarily remove comment count to fix crashes
|
||||
|
||||
## [1.57.0] - 2025-12-07
|
||||
|
||||
### Changed
|
||||
@@ -98,7 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Try to workaround an infrequent crash when scrolling hottest/newest posts
|
||||
- Try to work around an infrequent crash when scrolling hottest/newest posts
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -143,7 +257,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Fixed a crash when clicking an item on the bottom navigation bar
|
||||
too quickly
|
||||
- Removed buggy deeplinks
|
||||
- Removed buggy deep links
|
||||
- Clicking a username now correctly navigates to the right page in-app
|
||||
|
||||
## [1.44.0] - 2024-03-19
|
||||
@@ -400,7 +514,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix bug where newest posts tab actually showed hottest posts instead
|
||||
- Fix bug where the newest posts tab actually showed the hottest posts instead
|
||||
|
||||
## [1.15.0] - 2022-12-20
|
||||
|
||||
@@ -422,7 +536,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Fix crash when viewing [jcs](https://lobste.rs/u/jcs)' comments
|
||||
- Fix crash when viewing comments by ~jcs
|
||||
|
||||
## [1.12.0] - 2022-12-09
|
||||
|
||||
@@ -521,7 +635,16 @@ 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.57.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
|
||||
[1.59.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.58.0...v1.59.0
|
||||
[1.58.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.57.0...v1.58.0
|
||||
[1.57.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.55.0...v1.57.0
|
||||
[1.55.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.54.0...v1.55.0
|
||||
[1.54.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.53.0...v1.54.0
|
||||
|
||||
+56
-18
@@ -6,22 +6,25 @@
|
||||
*/
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import dev.msfjarvis.claw.gradle.addTestDependencies
|
||||
import dev.zacsweers.metro.gradle.DiagnosticSeverity
|
||||
|
||||
plugins {
|
||||
id("dev.msfjarvis.claw.android-application")
|
||||
id("dev.msfjarvis.claw.rename-artifacts")
|
||||
id("dev.msfjarvis.claw.kotlin-android")
|
||||
id("dev.msfjarvis.claw.kotlin-kapt")
|
||||
id("dev.msfjarvis.claw.sentry")
|
||||
id("dev.msfjarvis.claw.versioning-plugin")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.aboutlibraries)
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.modulegraphassert)
|
||||
alias(libs.plugins.whetstone)
|
||||
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)
|
||||
alias(libs.plugins.metro)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -29,6 +32,7 @@ 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"]
|
||||
@@ -39,6 +43,11 @@ android {
|
||||
|
||||
aboutLibraries.collect.gitHubApiToken = providers.environmentVariable("GITHUB_TOKEN").orNull
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
saveInSrc = true
|
||||
}
|
||||
|
||||
licensee {
|
||||
allow("Apache-2.0")
|
||||
allow("MIT")
|
||||
@@ -46,57 +55,78 @@ 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 }
|
||||
|
||||
moduleGraphAssert {
|
||||
assertOnAnyBuild = true
|
||||
maxHeight = 4
|
||||
restricted = arrayOf(":core -X> :.*")
|
||||
}
|
||||
|
||||
whetstone {
|
||||
addOns {
|
||||
compose.set(true)
|
||||
workManager.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
baselineProfile(projects.benchmark)
|
||||
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.aboutLibraries.compose.core)
|
||||
implementation(libs.aboutLibraries.core)
|
||||
implementation(libs.aboutLibraries.m3)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.compose.animation.core)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.androidx.compose.glance)
|
||||
implementation(libs.androidx.compose.glance.core)
|
||||
implementation(libs.androidx.compose.glance.m3)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.material3.adaptive.layout)
|
||||
implementation(libs.androidx.compose.material3.window.size)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.compose.runtime.saveable)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.compose.ui.unit)
|
||||
implementation(libs.androidx.annotation)
|
||||
implementation(libs.androidx.collection)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.lifecycle.common)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.material3.navigation3)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.dagger)
|
||||
implementation(libs.eithernet)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.javax.inject)
|
||||
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.napier)
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.metrox.android)
|
||||
implementation(libs.metrox.viewmodel)
|
||||
implementation(libs.metrox.viewmodel.compose)
|
||||
implementation(libs.okhttp.core)
|
||||
implementation(libs.okhttp.loggingInterceptor)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.sentry)
|
||||
implementation(libs.sentry.android.core)
|
||||
implementation(libs.sqldelight.extensions.coroutines)
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.unfurl)
|
||||
implementation(projects.api)
|
||||
@@ -105,7 +135,15 @@ dependencies {
|
||||
implementation(projects.database.core)
|
||||
implementation(projects.database.impl)
|
||||
implementation(projects.model)
|
||||
implementation(projects.web)
|
||||
|
||||
kapt(libs.dagger.compiler)
|
||||
compileOnly(libs.androidx.compose.glance.preview)
|
||||
|
||||
debugRuntimeOnly(libs.androidx.compose.glance)
|
||||
|
||||
runtimeOnly(libs.androidx.profileinstaller)
|
||||
|
||||
screenshotTestImplementation(libs.screenshot.validation.api)
|
||||
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
addTestDependencies(project)
|
||||
}
|
||||
|
||||
+122
-1
@@ -1,4 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.12.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.12.0-alpha02)" variant="all" version="8.12.0-alpha02">
|
||||
<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"
|
||||
message="This class should provide a default constructor (a public constructor with no arguments) (`dev.msfjarvis.claw.android.MainActivity`)"
|
||||
errorLine1=" android:name=".MainActivity""
|
||||
errorLine2=" ~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="28"
|
||||
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)."
|
||||
errorLine1=" ) togetherWith ExitTransition.KeepUntilTransitionsFinished"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt"
|
||||
line="259"
|
||||
column="49"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
-dontwarn android.window.OnBackInvokedCallback
|
||||
-dontwarn android.window.OnBackInvokedDispatcher
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn androidx.window.extensions.WindowExtensions
|
||||
-dontwarn androidx.window.extensions.WindowExtensionsProvider
|
||||
-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation
|
||||
@@ -26,3 +15,5 @@
|
||||
-dontwarn androidx.window.sidecar.SidecarProvider
|
||||
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
|
||||
-dontwarn io.mcarle.konvert.api.GeneratedKonverter
|
||||
-dontwarn com.google.re2j.Matcher
|
||||
-dontwarn com.google.re2j.Pattern
|
||||
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="claw-debug"
|
||||
android:host="comments" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="claw-debug"
|
||||
android:host="hottest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="claw-debug"
|
||||
android:host="newest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="claw-debug"
|
||||
android:host="saved" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -10,13 +10,16 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:name=".ClawApplication"
|
||||
android:appComponentFactory="dev.msfjarvis.claw.android.BackupSafeAppComponentFactory"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Claw.SplashScreen"
|
||||
tools:replace="android:appComponentFactory"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable
|
||||
android:shell="true"
|
||||
@@ -25,14 +28,42 @@
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="comments" android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="hottest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="newest" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="claw" android:host="saved" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".glance.WidgetClickActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
@@ -42,14 +73,10 @@
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
<meta-data
|
||||
android:name="com.deliveryhero.whetstone.worker.WhetstoneWorkerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".glance.WidgetReceiver"
|
||||
android:name=".glance.SavedPostsWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -59,6 +86,17 @@
|
||||
android:resource="@xml/saved_posts_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".glance.HottestPostsWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/hottest_posts_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Required: set your sentry.io project identifier (DSN) -->
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
@@ -80,5 +118,6 @@
|
||||
android:value="true" />
|
||||
<!-- set the environment for this build -->
|
||||
<meta-data android:name="io.sentry.environment" android:value="${sentryEnvironment}" />
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+29402
-17040
File diff suppressed because it is too large
Load Diff
+29402
-17040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.app.Application
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentFactory
|
||||
|
||||
/**
|
||||
* Custom variant of [android.app.AppComponentFactory] that skips [ClawApplication] init when the
|
||||
* app is running in a backup process for Android Auto Backup. In all normal cases, it will defer to
|
||||
* [MetroAppComponentFactory].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class BackupSafeAppComponentFactory : MetroAppComponentFactory() {
|
||||
|
||||
override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application {
|
||||
if (isBackupAgentProcess()) {
|
||||
return Application()
|
||||
}
|
||||
return super.instantiateApplicationCompat(cl, className)
|
||||
}
|
||||
|
||||
private fun isBackupAgentProcess(): Boolean {
|
||||
return try {
|
||||
val processName = Application.getProcessName()
|
||||
processName?.contains("backup") == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,42 +7,62 @@
|
||||
package dev.msfjarvis.claw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.deliveryhero.whetstone.Whetstone
|
||||
import com.deliveryhero.whetstone.app.ApplicationComponentOwner
|
||||
import com.deliveryhero.whetstone.app.ContributesAppInjector
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidgetReceiver
|
||||
import dev.msfjarvis.claw.android.injection.AppGraph
|
||||
import dev.msfjarvis.claw.android.work.SavedPostUpdaterWorker
|
||||
import dev.msfjarvis.claw.core.injection.AppPlugin
|
||||
import javax.inject.Inject
|
||||
import dev.msfjarvis.claw.android.work.TagExpirationCleanupWorker
|
||||
import dev.zacsweers.metro.createGraphFactory
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentProviders
|
||||
import dev.zacsweers.metrox.android.MetroApplication
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesAppInjector(generateAppComponent = true)
|
||||
class ClawApplication : Application(), ApplicationComponentOwner {
|
||||
class ClawApplication : Application(), MetroApplication {
|
||||
|
||||
override val applicationComponent = GeneratedApplicationComponent.create(this)
|
||||
val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(this) }
|
||||
|
||||
@Inject lateinit var plugins: Set<@JvmSuppressWildcards AppPlugin>
|
||||
override val appComponentProviders: MetroAppComponentProviders
|
||||
get() = appGraph
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCreate() {
|
||||
Whetstone.inject(this)
|
||||
super.onCreate()
|
||||
plugins.forEach { plugin -> plugin.apply(this) }
|
||||
appGraph.plugins.forEach { plugin -> plugin.apply(this) }
|
||||
val postUpdateWorkRequest =
|
||||
OneTimeWorkRequestBuilder<SavedPostUpdaterWorker>()
|
||||
PeriodicWorkRequestBuilder<SavedPostUpdaterWorker>(24, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(this)
|
||||
.enqueueUniqueWork(
|
||||
uniqueWorkName = "updateSavedPosts",
|
||||
existingWorkPolicy = ExistingWorkPolicy.KEEP,
|
||||
request = postUpdateWorkRequest,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val POST_REFRESH_PERIOD = 24L // 24 hours
|
||||
appGraph.workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = "updateSavedPosts",
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
|
||||
request = postUpdateWorkRequest,
|
||||
)
|
||||
val tagCleanupWorkRequest =
|
||||
PeriodicWorkRequestBuilder<TagExpirationCleanupWorker>(24, TimeUnit.HOURS).build()
|
||||
appGraph.workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = "cleanupExpiredTags",
|
||||
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
|
||||
request = tagCleanupWorkRequest,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
GlobalScope.launch(appGraph.mainDispatcher) {
|
||||
try {
|
||||
GlanceAppWidgetManager(applicationContext)
|
||||
.setWidgetPreviews(SavedPostsWidgetReceiver::class)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// Workaround for Motorola Android 15 bug where AppWidgetServiceImpl incorrectly
|
||||
// reports registered widgets as invalid when setting previews.
|
||||
// See: https://claw.sentry.io/share/issue/f8ee09821d8840b9b86293d3ffb627d3/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -15,46 +17,96 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.deliveryhero.whetstone.Whetstone
|
||||
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Comments
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Hottest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Newest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Saved
|
||||
import dev.msfjarvis.claw.android.ui.screens.LobstersPostsScreen
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
import javax.inject.Inject
|
||||
import dev.msfjarvis.claw.core.injection.InjectedViewModelFactory
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.ActivityKey
|
||||
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@ContributesActivityInjector
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ContributesIntoMap(AppScope::class, binding<Activity>())
|
||||
@ActivityKey
|
||||
@Inject
|
||||
class MainActivity(
|
||||
private val uriHandler: UriHandler,
|
||||
private val viewModelFactory: InjectedViewModelFactory,
|
||||
) : ComponentActivity() {
|
||||
|
||||
override val defaultViewModelProviderFactory: ViewModelProvider.Factory
|
||||
get() = viewModelFactory
|
||||
|
||||
@Inject lateinit var urlLauncher: UrlLauncher
|
||||
var webUri: String? = null
|
||||
var deepLinkDestination by mutableStateOf<NavKey?>(null)
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
Whetstone.inject(this)
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
|
||||
// Don't set navigation bar style, the default matches the platform behavior.
|
||||
)
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
LobstersTheme(
|
||||
dynamicColor = true,
|
||||
providedValues = arrayOf(LocalUriHandler provides urlLauncher),
|
||||
providedValues =
|
||||
arrayOf(
|
||||
LocalUriHandler provides uriHandler,
|
||||
LocalMetroViewModelFactory provides viewModelFactory,
|
||||
),
|
||||
) {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
LobstersPostsScreen(
|
||||
urlLauncher = urlLauncher,
|
||||
uriHandler = uriHandler,
|
||||
windowSizeClass = windowSizeClass,
|
||||
setWebUri = { url -> webUri = url },
|
||||
deepLinkDestination = deepLinkDestination,
|
||||
clearDeepLink = { deepLinkDestination = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == BuildConfig.DEEPLINK_SCHEME) {
|
||||
when (data.host) {
|
||||
"comments" -> {
|
||||
val postId = data.pathSegments.firstOrNull()
|
||||
if (postId != null) {
|
||||
deepLinkDestination = Comments(postId)
|
||||
}
|
||||
}
|
||||
"newest" -> deepLinkDestination = Newest
|
||||
"hottest" -> deepLinkDestination = Hottest
|
||||
"saved" -> deepLinkDestination = Saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
val uri = webUri
|
||||
@@ -66,8 +118,4 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAVIGATION_KEY = "postId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.ClawApplication
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePost
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromCachedRemotePost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class HottestPostsWidget : GlanceAppWidget() {
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appGraph = (context.applicationContext as ClawApplication).appGraph
|
||||
val cachedRemotePostsRepository = appGraph.cachedRemotePostsRepository
|
||||
val tagBlockRepository = appGraph.tagBlockRepository
|
||||
val filteredTags =
|
||||
try {
|
||||
tagBlockRepository.getSavedTags().first()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
val posts =
|
||||
when (val postsResult = appGraph.lobstersApi.getHottestPosts(1)) {
|
||||
is ApiResult.Success -> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
cachedRemotePostsRepository.savePosts(cachedPosts)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore caching failures - widget should still render
|
||||
}
|
||||
uiPosts.toImmutableList()
|
||||
}
|
||||
else -> {
|
||||
// Fall back to cached posts when network fails
|
||||
cachedRemotePostsRepository
|
||||
.getCachedPosts()
|
||||
.map(UIPost::fromCachedRemotePost)
|
||||
.toImmutableList()
|
||||
}
|
||||
}
|
||||
// Filter out posts with tags that are in the filtered tags set
|
||||
val filteredPosts =
|
||||
if (filteredTags.isEmpty()) {
|
||||
posts
|
||||
} else {
|
||||
posts
|
||||
.filter { post -> post.tags.none { tag -> filteredTags.contains(tag) } }
|
||||
.toImmutableList()
|
||||
}
|
||||
provideContent { LobstersGlanceTheme { Content(context, filteredPosts) } }
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
provideContent { Content(context, samplePosts()) }
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun Content(
|
||||
context: Context,
|
||||
posts: ImmutableList<UIPost>,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
WidgetContainer(
|
||||
"Hottest posts",
|
||||
listContent = {
|
||||
items(posts) { post ->
|
||||
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) { WidgetPostEntry(post) }
|
||||
}
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
GlanceModifier.background(GlanceTheme.colors.primary)
|
||||
.cornerRadius(20.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 10.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://hottest".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(LocalContext.current, MainActivity::class.java)
|
||||
}
|
||||
)
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.see_more_posts),
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -6,25 +6,26 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import com.deliveryhero.whetstone.Whetstone
|
||||
import com.deliveryhero.whetstone.broadcastreceiver.ContributesBroadcastReceiverInjector
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.BroadcastReceiverKey
|
||||
|
||||
@ContributesBroadcastReceiverInjector
|
||||
class WidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
@Inject lateinit var viewModel: ClawViewModel
|
||||
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
|
||||
@BroadcastReceiverKey
|
||||
@Inject
|
||||
class HottestPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = SavedPostsWidget(viewModel.savedPosts)
|
||||
get() = HottestPostsWidget()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Whetstone.inject(this, context)
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
}
|
||||
@@ -6,69 +6,106 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.appWidgetBackground
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.preview.ExperimentalGlancePreviewApi
|
||||
import androidx.glance.preview.Preview
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.common.theme.DarkThemeColors
|
||||
import dev.msfjarvis.claw.common.theme.LightThemeColors
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.ClawApplication
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromSavedPost
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class SavedPostsWidget(private val posts: Flow<List<UIPost>>) : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val postWindow = posts.first().take(50).toImmutableList()
|
||||
provideContent {
|
||||
GlanceTheme(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
|
||||
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors)
|
||||
) {
|
||||
WidgetHost(postWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class SavedPostsWidget : GlanceAppWidget() {
|
||||
|
||||
@Composable
|
||||
fun WidgetHost(posts: ImmutableList<UIPost>, modifier: GlanceModifier = GlanceModifier) {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
modifier.fillMaxSize().background(GlanceTheme.colors.background).appWidgetBackground(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
item {
|
||||
val style = MaterialTheme.typography.titleLarge
|
||||
Text(
|
||||
text = "Saved posts",
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onBackground,
|
||||
fontSize = style.fontSize,
|
||||
fontWeight = style.fontWeight.toGlance(),
|
||||
fontStyle = style.fontStyle.toGlance(),
|
||||
),
|
||||
modifier = GlanceModifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(posts) { post -> WidgetListEntry(post = post) }
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appGraph = (context.applicationContext as ClawApplication).appGraph
|
||||
val posts = appGraph.savedPostsRepository.getRecentPosts(50)
|
||||
val postWindow = posts.first().map(UIPost::fromSavedPost).toImmutableList()
|
||||
provideContent { LobstersGlanceTheme { Content(postWindow) } }
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
provideContent { Content(samplePosts()) }
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@Composable
|
||||
private fun Content(posts: ImmutableList<UIPost>, modifier: GlanceModifier = GlanceModifier) {
|
||||
val context = LocalContext.current
|
||||
WidgetContainer(
|
||||
title = context.getString(R.string.saved_posts),
|
||||
listContent = {
|
||||
items(posts) { post ->
|
||||
Box(GlanceModifier.padding(horizontal = 16.dp, vertical = 4.dp)) { WidgetPostEntry(post) }
|
||||
}
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(16.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
GlanceModifier.background(GlanceTheme.colors.primary)
|
||||
.cornerRadius(20.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 10.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://saved".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(LocalContext.current, MainActivity::class.java)
|
||||
}
|
||||
)
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.see_more_posts),
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onPrimary, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("ComposeUnstableReceiver")
|
||||
@OptIn(ExperimentalGlancePreviewApi::class)
|
||||
@Preview
|
||||
@GlanceComposable
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
Content(samplePosts())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.glance
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.binding
|
||||
import dev.zacsweers.metrox.android.BroadcastReceiverKey
|
||||
|
||||
@ContributesIntoMap(AppScope::class, binding<BroadcastReceiver>())
|
||||
@BroadcastReceiverKey
|
||||
@Inject
|
||||
class SavedPostsWidgetReceiver() : GlanceAppWidgetReceiver() {
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = SavedPostsWidget()
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.glance
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryLevel
|
||||
|
||||
/**
|
||||
* Trampoline activity for handling widget clicks with explicit components.
|
||||
*
|
||||
* This activity is required to fix COMPOSE-LOBSTERS-9X/9P/8M. Glance's LazyColumn requires all
|
||||
* actionStartActivity intents to have an explicit component set. For external URLs, we route
|
||||
* through this activity which then launches the appropriate intent (either internal deep link or
|
||||
* external browser).
|
||||
*
|
||||
* @see:
|
||||
* https://developer.android.com/reference/kotlin/androidx/glance/appwidget/action/package-summary#actionStartActivity(android.content.Intent,androidx.glance.action.ActionParameters)
|
||||
*/
|
||||
class WidgetClickActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val url = intent.getStringExtra(EXTRA_URL)
|
||||
val shortId = intent.getStringExtra(EXTRA_SHORT_ID)
|
||||
val action = intent.action
|
||||
|
||||
when {
|
||||
// Handle comments deep link
|
||||
action == ACTION_OPEN_COMMENTS && shortId != null -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "Comment deeplink"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
val commentsIntent =
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(this@WidgetClickActivity, MainActivity::class.java)
|
||||
}
|
||||
startActivity(commentsIntent)
|
||||
}
|
||||
// Handle external URL
|
||||
url != null && (url.startsWith("http://") || url.startsWith("https://")) -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "External URL"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
val urlIntent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
startActivity(urlIntent)
|
||||
}
|
||||
// Handle internal/relative URL as comments link
|
||||
url != null && (url.startsWith('/') || url.isEmpty()) -> {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb().apply {
|
||||
category = "widget_click_debug"
|
||||
message = "Relative URL, parsed as a comment link"
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
)
|
||||
// For internal URLs without shortId, extract it from the URL or use comments action
|
||||
val commentsIntent =
|
||||
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId".toUri())
|
||||
.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setClass(this@WidgetClickActivity, MainActivity::class.java)
|
||||
}
|
||||
startActivity(commentsIntent)
|
||||
}
|
||||
}
|
||||
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_OPEN_COMMENTS = "dev.msfjarvis.claw.OPEN_COMMENTS"
|
||||
const val EXTRA_URL = "dev.msfjarvis.claw.EXTRA_URL"
|
||||
const val EXTRA_SHORT_ID = "dev.msfjarvis.claw.EXTRA_SHORT_ID"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +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.android.glance
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters.Key
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.MainActivity.Companion.NAVIGATION_KEY
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
|
||||
private val destinationKey = Key<String>(NAVIGATION_KEY)
|
||||
|
||||
@Composable
|
||||
@GlanceComposable
|
||||
fun WidgetListEntry(post: UIPost, modifier: GlanceModifier = GlanceModifier) {
|
||||
val titleStyle = MaterialTheme.typography.titleMedium
|
||||
val commentsAction =
|
||||
actionStartActivity<MainActivity>(actionParametersOf(destinationKey to post.shortId))
|
||||
// If the URL starts with a /, it's a relative URL and we should open the comments page directly.
|
||||
val postAction =
|
||||
if (post.url.startsWith('/')) commentsAction
|
||||
else actionStartActivity(Intent(Intent.ACTION_VIEW, post.url.toUri()))
|
||||
Box(modifier.padding(8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
GlanceModifier.fillMaxWidth()
|
||||
.background(GlanceTheme.colors.surfaceVariant)
|
||||
.cornerRadius(4.dp)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = post.title,
|
||||
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp).clickable(postAction),
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = titleStyle.fontSize,
|
||||
fontWeight = titleStyle.fontWeight.toGlance(),
|
||||
fontStyle = titleStyle.fontStyle.toGlance(),
|
||||
),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_comment),
|
||||
contentDescription = "${post.commentCount} comments",
|
||||
modifier = GlanceModifier.padding(end = 4.dp).clickable(commentsAction),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.android.glance
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.preview.ExperimentalGlancePreviewApi
|
||||
import androidx.glance.preview.Preview
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
|
||||
@SuppressLint("ArgInFormattedQuantityStringRes")
|
||||
@Composable
|
||||
@GlanceComposable
|
||||
fun WidgetPostEntry(post: UIPost, modifier: GlanceModifier = GlanceModifier) {
|
||||
val titleStyle = MaterialTheme.typography.bodyMedium
|
||||
val context = LocalContext.current
|
||||
val commentsAction =
|
||||
actionStartActivity(
|
||||
Intent(context, WidgetClickActivity::class.java).apply {
|
||||
action = WidgetClickActivity.ACTION_OPEN_COMMENTS
|
||||
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
|
||||
}
|
||||
)
|
||||
val postAction =
|
||||
if (post.url.startsWith('/') || post.url.isEmpty()) commentsAction
|
||||
else
|
||||
actionStartActivity(
|
||||
Intent(context, WidgetClickActivity::class.java).apply {
|
||||
putExtra(WidgetClickActivity.EXTRA_URL, post.url)
|
||||
putExtra(WidgetClickActivity.EXTRA_SHORT_ID, post.shortId)
|
||||
}
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
modifier.fillMaxWidth().cornerRadius(8.dp).background(GlanceTheme.colors.secondaryContainer),
|
||||
) {
|
||||
Text(
|
||||
text = post.title,
|
||||
modifier =
|
||||
GlanceModifier.defaultWeight()
|
||||
.clickable(postAction)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.padding(start = 4.dp),
|
||||
maxLines = 1,
|
||||
style =
|
||||
TextStyle(color = GlanceTheme.colors.onSecondaryContainer, fontSize = titleStyle.fontSize),
|
||||
)
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_comment),
|
||||
contentDescription =
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.comments,
|
||||
post.commentCount,
|
||||
post.commentCount,
|
||||
),
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSecondaryContainer),
|
||||
modifier =
|
||||
GlanceModifier.clickable(commentsAction)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalGlancePreviewApi::class)
|
||||
@Preview
|
||||
@GlanceComposable
|
||||
@Composable
|
||||
private fun WidgetPostEntryPreview() {
|
||||
WidgetPostEntry(samplePosts(1).first())
|
||||
}
|
||||
@@ -6,22 +6,67 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.LazyListScope
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST
|
||||
import dev.msfjarvis.claw.common.theme.DarkThemeColors
|
||||
import dev.msfjarvis.claw.common.theme.LightThemeColors
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
fun FontWeight?.toGlance(): androidx.glance.text.FontWeight? {
|
||||
return when (this) {
|
||||
FontWeight.Normal -> androidx.glance.text.FontWeight.Normal
|
||||
FontWeight.Medium -> androidx.glance.text.FontWeight.Medium
|
||||
FontWeight.Bold -> androidx.glance.text.FontWeight.Bold
|
||||
else -> null
|
||||
@Composable
|
||||
fun LobstersGlanceTheme(content: @GlanceComposable @Composable () -> Unit) {
|
||||
GlanceTheme(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
|
||||
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors),
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WidgetContainer(
|
||||
title: String,
|
||||
listContent: LazyListScope.() -> Unit,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().background(GlanceTheme.colors.widgetBackground).cornerRadius(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = GlanceModifier.padding(16.dp).fillMaxWidth(),
|
||||
) {
|
||||
Text(text = title, style = TextStyle(fontSize = 18.sp, color = GlanceTheme.colors.onSurface))
|
||||
}
|
||||
LazyColumn(content = listContent)
|
||||
}
|
||||
}
|
||||
|
||||
fun FontStyle?.toGlance(): androidx.glance.text.FontStyle? {
|
||||
return when (this) {
|
||||
FontStyle.Normal -> androidx.glance.text.FontStyle.Normal
|
||||
FontStyle.Italic -> androidx.glance.text.FontStyle.Italic
|
||||
else -> null
|
||||
}
|
||||
@SuppressLint("VisibleForTests")
|
||||
fun samplePosts(
|
||||
count: Int = 5,
|
||||
mutator: UIPost.(idx: Int) -> UIPost = { this },
|
||||
): PersistentList<UIPost> {
|
||||
return buildList(count) { repeat(count) { add(TEST_POST.mutator(it)) } }.toPersistentList()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.injection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import dev.msfjarvis.claw.android.viewmodel.CachedRemotePostsRepository
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
|
||||
import dev.msfjarvis.claw.core.injection.AppPlugin
|
||||
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.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metrox.android.MetroAppComponentProviders
|
||||
import dev.zacsweers.metrox.viewmodel.ViewModelGraph
|
||||
import kotlin.reflect.KClass
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
@DependencyGraph(AppScope::class)
|
||||
interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
|
||||
val plugins: Set<AppPlugin>
|
||||
|
||||
val workManager: WorkManager
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesWorkManager(application: Context): WorkManager {
|
||||
return WorkManager.getInstance(application)
|
||||
}
|
||||
|
||||
@Multibinds
|
||||
val workerProviders:
|
||||
Map<KClass<out ListenableWorker>, () -> InjectedWorkerFactory.WorkerInstanceFactory<*>>
|
||||
|
||||
val workerFactory: InjectedWorkerFactory
|
||||
|
||||
val savedPostsRepository: SavedPostsRepository
|
||||
|
||||
val cachedRemotePostsRepository: CachedRemotePostsRepository
|
||||
|
||||
val lobstersApi: LobstersApi
|
||||
|
||||
val tagBlockRepository: TagBlockRepository
|
||||
|
||||
@MainDispatcher val mainDispatcher: CoroutineDispatcher
|
||||
|
||||
@DependencyGraph.Factory
|
||||
fun interface Factory {
|
||||
fun create(@Provides context: Context): AppGraph
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -6,27 +6,27 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.github.aakira.napier.Napier
|
||||
import android.util.Log
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import me.saket.unfurl.UnfurlLogger
|
||||
import me.saket.unfurl.Unfurler
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
object MetadataExtractorModule {
|
||||
@Provides
|
||||
fun provideUnfurlLogger(): UnfurlLogger {
|
||||
return object : UnfurlLogger {
|
||||
override fun log(message: String) {
|
||||
Napier.d(tag = "Unfurler") { message }
|
||||
Log.d("Unfurler", message)
|
||||
}
|
||||
|
||||
override fun log(e: Throwable, message: String) {
|
||||
Napier.e(tag = "Unfurler", throwable = e) { message }
|
||||
Log.e("Unfurler", message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.injection
|
||||
|
||||
import android.app.Application
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.core.injection.AppPlugin
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.TypeCheckHint.OKHTTP_REQUEST
|
||||
import io.sentry.android.core.SentryAndroid
|
||||
import okhttp3.Request
|
||||
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
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)
|
||||
|
||||
// Drop all OkHttp errors that are not about Lobsters specifically.
|
||||
if (request != null && !LobstersApi.BASE_URL.contains(request.url.host)) {
|
||||
null
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +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.android.injection
|
||||
|
||||
import android.content.Context
|
||||
import com.deliveryhero.whetstone.activity.ActivityScope
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
|
||||
@Module
|
||||
@ContributesTo(ActivityScope::class)
|
||||
object UrlLauncherModule {
|
||||
@Provides
|
||||
fun provideUrlLauncher(@ForScope(ActivityScope::class) context: Context): UrlLauncher {
|
||||
return UrlLauncher(context)
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,14 @@ import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerFactory
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.squareup.anvil.annotations.ContributesMultibinding
|
||||
import dev.msfjarvis.claw.core.injection.AppPlugin
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesIntoSet
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
@ContributesMultibinding(ApplicationScope::class)
|
||||
class WorkManagerPlugin @Inject constructor(private val workerFactory: WorkerFactory) : AppPlugin {
|
||||
@ContributesIntoSet(AppScope::class)
|
||||
@Inject
|
||||
class WorkManagerPlugin(private val workerFactory: WorkerFactory) : AppPlugin {
|
||||
override fun apply(application: Application) {
|
||||
WorkManager.initialize(
|
||||
application,
|
||||
|
||||
@@ -10,23 +10,22 @@ import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.slack.eithernet.ApiResult.Failure
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dev.msfjarvis.claw.api.toError
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LobstersPagingSource
|
||||
@AssistedInject
|
||||
constructor(
|
||||
class LobstersPagingSource(
|
||||
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : PagingSource<Int, UIPost>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
|
||||
val page = params.key ?: STARTING_PAGE_INDEX
|
||||
|
||||
@@ -9,9 +9,6 @@ package dev.msfjarvis.claw.android.paging
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||
import dev.msfjarvis.claw.api.LobstersSearchApi
|
||||
@@ -20,6 +17,9 @@ import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toUIPost
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -30,12 +30,11 @@ import kotlinx.coroutines.withContext
|
||||
* relevant parameters to short-circuit when there is no query specified in order to avoid calling
|
||||
* the API at all.
|
||||
*/
|
||||
class SearchPagingSource
|
||||
@AssistedInject
|
||||
constructor(
|
||||
class SearchPagingSource(
|
||||
private val searchApi: LobstersSearchApi,
|
||||
@Assisted private val queryProvider: () -> String,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : PagingSource<Int, UIPost>() {
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
|
||||
val searchQuery = queryProvider()
|
||||
|
||||
@@ -45,13 +45,13 @@ fun ClawAppBar(
|
||||
IconButton(onClick = { if (popBackStack() == null) activity?.finish() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Go back to previous screen",
|
||||
contentDescription = stringResource(R.string.go_back_to_previous_screen),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "The app icon for Claw",
|
||||
contentDescription = stringResource(R.string.the_app_icon_for_claw),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
@@ -64,10 +64,16 @@ fun ClawAppBar(
|
||||
actions = {
|
||||
if (isTopLevel) {
|
||||
IconButton(onClick = { navigateTo(Search) }) {
|
||||
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search posts")
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.search_posts),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { navigateTo(Settings) }) {
|
||||
Icon(imageVector = Icons.Filled.Tune, contentDescription = "Settings")
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.settings),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+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
|
||||
|
||||
@@ -8,22 +8,22 @@ package dev.msfjarvis.claw.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
|
||||
fun PostActions(
|
||||
context: Context,
|
||||
urlLauncher: UrlLauncher,
|
||||
uriHandler: UriHandler,
|
||||
viewModel: ClawViewModel,
|
||||
navigateToComments: (String) -> Unit,
|
||||
): PostActions {
|
||||
return object : PostActions {
|
||||
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {
|
||||
viewModel.markPostAsRead(postId)
|
||||
urlLauncher.openUri(postUrl.ifEmpty { commentsUrl })
|
||||
uriHandler.openUri(postUrl.ifEmpty { commentsUrl })
|
||||
}
|
||||
|
||||
override fun viewComments(postId: String) {
|
||||
@@ -32,7 +32,7 @@ fun PostActions(
|
||||
}
|
||||
|
||||
override fun viewCommentsPage(post: UIPost) {
|
||||
urlLauncher.openUri(post.commentsUrl)
|
||||
uriHandler.openUri(post.commentsUrl)
|
||||
}
|
||||
|
||||
override fun toggleSave(post: UIPost) {
|
||||
|
||||
@@ -25,7 +25,9 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.ui.decorations.MonthHeader
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
@@ -46,17 +48,27 @@ fun DatabasePosts(
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Inbox,
|
||||
contentDescription = "Empty inbox icon",
|
||||
contentDescription = stringResource(R.string.empty_inbox_icon),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(36.dp),
|
||||
)
|
||||
Text(text = "No saved posts", style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
text = stringResource(R.string.no_saved_posts),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState, contentPadding = contentPadding) {
|
||||
items.forEach { (month, posts) ->
|
||||
stickyHeader(contentType = "month-header") { MonthHeader(label = month) }
|
||||
items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item ->
|
||||
LobstersListItem(item = item, postActions = postActions)
|
||||
val isSaved = postActions.isPostSaved(item)
|
||||
val isRead = postActions.isPostRead(item)
|
||||
LobstersListItem(
|
||||
item = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.glance.samplePosts
|
||||
import dev.msfjarvis.claw.common.posts.LobstersCard
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
@@ -25,7 +25,13 @@ import me.saket.swipe.SwipeAction
|
||||
import me.saket.swipe.SwipeableActionsBox
|
||||
|
||||
@Composable
|
||||
fun LobstersListItem(item: UIPost, postActions: PostActions, modifier: Modifier = Modifier) {
|
||||
fun LobstersListItem(
|
||||
item: UIPost,
|
||||
isSaved: Boolean,
|
||||
isRead: Boolean,
|
||||
postActions: PostActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val commentsAction =
|
||||
SwipeAction(
|
||||
icon = rememberVectorPainter(Icons.AutoMirrored.Filled.Reply),
|
||||
@@ -44,12 +50,25 @@ fun LobstersListItem(item: UIPost, postActions: PostActions, modifier: Modifier
|
||||
swipeThreshold = 80.dp,
|
||||
backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
LobstersCard(post = item, postActions = postActions, modifier = modifier)
|
||||
LobstersCard(
|
||||
post = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
private fun ItemPreview() {
|
||||
LobstersTheme { LobstersListItem(item = TEST_POST, postActions = TEST_POST_ACTIONS) }
|
||||
LobstersTheme {
|
||||
LobstersListItem(
|
||||
item = samplePosts(1).first(),
|
||||
isSaved = true,
|
||||
isRead = true,
|
||||
postActions = TEST_POST_ACTIONS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,15 +31,16 @@ import androidx.paging.PagingData
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import dev.msfjarvis.claw.android.glance.samplePosts
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST
|
||||
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.NetworkError
|
||||
import dev.msfjarvis.claw.common.ui.ProgressBar
|
||||
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -49,6 +50,7 @@ fun NetworkPosts(
|
||||
listState: LazyListState,
|
||||
postActions: PostActions,
|
||||
contentPadding: PaddingValues,
|
||||
filteredTags: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
|
||||
@@ -77,13 +79,24 @@ fun NetworkPosts(
|
||||
LazyColumn(contentPadding = contentPadding, state = listState) {
|
||||
items(
|
||||
count = lazyPagingItems.itemCount,
|
||||
key = lazyPagingItems.itemKey { it.shortId },
|
||||
// Intentionally keyed on just the index to avoid crashes
|
||||
key = { index -> index },
|
||||
contentType = lazyPagingItems.itemContentType { "LobstersItem" },
|
||||
) { index ->
|
||||
val item = lazyPagingItems[index]
|
||||
if (item != null) {
|
||||
LobstersListItem(item = item, postActions = postActions)
|
||||
HorizontalDivider()
|
||||
val shouldShowPost = item.tags.none { tag -> filteredTags.contains(tag) }
|
||||
if (shouldShowPost) {
|
||||
val isSaved = postActions.isPostSaved(item)
|
||||
val isRead = postActions.isPostRead(item)
|
||||
LobstersListItem(
|
||||
item = item,
|
||||
isSaved = isSaved,
|
||||
isRead = isRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lazyPagingItems.loadState.append == LoadState.Loading) {
|
||||
@@ -104,7 +117,7 @@ fun NetworkPosts(
|
||||
@DevicePreviews
|
||||
@Composable
|
||||
private fun ListPreview() {
|
||||
val items = List(20) { TEST_POST.copy(shortId = "${TEST_POST.shortId}${it}") }
|
||||
val items = samplePosts(20) { this.copy(shortId = "${this.shortId}${it}") }
|
||||
val flow = MutableStateFlow(PagingData.from(items))
|
||||
LobstersTheme {
|
||||
NetworkPosts(
|
||||
@@ -112,6 +125,7 @@ private fun ListPreview() {
|
||||
listState = rememberLazyListState(),
|
||||
postActions = TEST_POST_ACTIONS,
|
||||
contentPadding = PaddingValues(),
|
||||
filteredTags = persistentSetOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.paging.compose.LazyPagingItems
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.ui.SearchBar
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Composable
|
||||
fun SearchList(
|
||||
@@ -28,6 +29,7 @@ fun SearchList(
|
||||
searchQuery: String,
|
||||
setSearchQuery: (String) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
filteredTags: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val triggerSearch = { query: String ->
|
||||
@@ -46,6 +48,7 @@ fun SearchList(
|
||||
listState = listState,
|
||||
postActions = postActions,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
filteredTags = filteredTags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +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.android.ui.login
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.google.accompanist.web.AccompanistWebViewClient
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import io.github.aakira.napier.Napier
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
@Suppress("Unused")
|
||||
@Composable
|
||||
fun LobstersLoginWebView(modifier: Modifier = Modifier) {
|
||||
val state = rememberWebViewState(url = "https://lobste.rs/login")
|
||||
val webClient = remember {
|
||||
object : AccompanistWebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
Napier.d(tag = "WebView") { "onPageFinished(url=$url)" }
|
||||
if (url != null && url.toHttpUrl() == LobstersApi.BASE_URL.toHttpUrl()) {
|
||||
val cookies = CookieManager.getInstance().getCookie(LobstersApi.BASE_URL)
|
||||
val trap =
|
||||
cookies
|
||||
.split(";")
|
||||
.associate {
|
||||
val split = it.split("=")
|
||||
split[0] to split[1]
|
||||
}["lobsters_trap"]
|
||||
Napier.d(tag = "WebView") { "lobsters_trap=${trap}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WebView(state = state, client = webClient, modifier = modifier, captureBackPresses = false)
|
||||
}
|
||||
@@ -1,62 +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.android.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
/**
|
||||
* Naive implementation of a simple navigation back stack, backed by a
|
||||
* [androidx.compose.runtime.snapshots.SnapshotStateList].
|
||||
*
|
||||
* Using a [java.util.Stack] would've made the API ridiculously simpler, but we would lose all the
|
||||
* cool Compose benefits.
|
||||
*
|
||||
* Stacks function in a completely opposite order of lists, which means when you call
|
||||
* first/firstOrNull on a stack, you expect to receive the item you added *last*, since stacks add
|
||||
* to the front while lists add behind. To counter these expectations with the actual backing data
|
||||
* structure, many APIs in this class are the inverse of identically named functions on [List].
|
||||
*/
|
||||
class ClawBackStack(private val initialDestination: NavKey) {
|
||||
/**
|
||||
* Marker interface for destinations that occupy the "top" level of the back stack.
|
||||
*
|
||||
* This is used by [dev.msfjarvis.claw.android.ui.navigation.ClawBackStack.add] to ensure that
|
||||
* duplicate instances of these destinations do not end up in the back stack.
|
||||
*/
|
||||
interface TopLevelDestination
|
||||
|
||||
val backStack = mutableStateListOf(initialDestination)
|
||||
|
||||
/**
|
||||
* Pushes a new destination onto the stack.
|
||||
*
|
||||
* Top level destinations are specially handled to ensure that the distance between the incoming
|
||||
* [destination] and the [initialDestination] cannot have anything in between. This prevents users
|
||||
* from getting stuck in a frustratingly long stack of top level destinations that are so easily
|
||||
* accessible that they have no reason to be on the stack.
|
||||
*/
|
||||
fun add(destination: NavKey) {
|
||||
if (destination is TopLevelDestination) {
|
||||
backStack.clear()
|
||||
if (destination != initialDestination) {
|
||||
backStack.add(initialDestination)
|
||||
}
|
||||
}
|
||||
backStack.add(destination)
|
||||
}
|
||||
|
||||
/** Checks if the "top" item in the back stack is an instance of [TopLevelDestination]. */
|
||||
fun isOnTopLevelDestination(): Boolean {
|
||||
val top = firstOrNull()
|
||||
return top != null && top is TopLevelDestination
|
||||
}
|
||||
|
||||
fun firstOrNull() = backStack.lastOrNull()
|
||||
|
||||
fun removeLastOrNull() = backStack.removeLastOrNull()
|
||||
}
|
||||
@@ -16,19 +16,29 @@ import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material.icons.outlined.Whatshot
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack.TopLevelDestination
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface TopLevelDestination
|
||||
|
||||
interface NonStackable
|
||||
|
||||
@Parcelize @Serializable data object Hottest : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Newest : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Saved : NavKey, TopLevelDestination, Parcelable
|
||||
|
||||
@Parcelize @Serializable data class Comments(val postId: String) : NavKey, Parcelable
|
||||
@Parcelize @Serializable data class Comments(val postId: String) : NavKey, Parcelable, NonStackable
|
||||
|
||||
@Parcelize @Serializable data class User(val username: String) : NavKey, Parcelable
|
||||
@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
|
||||
|
||||
@@ -38,6 +48,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Parcelize @Serializable data object TagFiltering : NavKey, Parcelable
|
||||
|
||||
@Parcelize @Serializable data object Login : NavKey, Parcelable
|
||||
|
||||
enum class AppDestinations(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/** Taken from https://github.com/getsentry/sentry-java/pull/5023 */
|
||||
@file:Suppress("UnstableApiUsage") // Sentry internal APIs are used by this module
|
||||
|
||||
package dev.msfjarvis.claw.android.ui.navigation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.BuildConfig
|
||||
import io.sentry.Hint
|
||||
import io.sentry.IScopes
|
||||
import io.sentry.ITransaction
|
||||
import io.sentry.ScopesAdapter
|
||||
import io.sentry.SentryIntegrationPackageStorage
|
||||
import io.sentry.SentryLevel
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.SpanStatus
|
||||
import io.sentry.TransactionContext
|
||||
import io.sentry.TransactionOptions
|
||||
import io.sentry.TypeCheckHint
|
||||
import io.sentry.protocol.TransactionNameSource
|
||||
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val TRACE_ORIGIN = "auto.navigation.navigation3"
|
||||
private const val NAVIGATION_OP = "navigation"
|
||||
|
||||
/** Holder for the active navigation transaction in a composition scope. */
|
||||
internal class NavigationTransactionHolder {
|
||||
internal var activeTransaction: ITransaction? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* A Composable that observes a back stack [List] and captures a [Breadcrumb] and starts an
|
||||
* [ITransaction] for each navigation event, sending them to Sentry.
|
||||
*
|
||||
* This integration is designed for Android Navigation 3 which uses a back stack-based approach with
|
||||
* Compose state observation instead of traditional listeners.
|
||||
*
|
||||
* Each invocation of this composable maintains independent transaction state, allowing multiple
|
||||
* navigation instances (e.g., in split-screen or multi-pane layouts) to coexist without
|
||||
* interference.
|
||||
*
|
||||
* @param T The type of keys in the back stack
|
||||
* @param backStack The current back stack to observe for navigation changes
|
||||
* @param enableNavigationBreadcrumbs Whether the integration should capture breadcrumbs for
|
||||
* navigation events.
|
||||
* @param enableNavigationTracing Whether the integration should start a new idle [ITransaction]
|
||||
* with [SentryOptions.idleTimeout] for navigation events.
|
||||
* @param keyToRoute A function to extract a route name from a back stack key. Defaults to
|
||||
* [Any.toString].
|
||||
* @param scopes The [IScopes] instance to use for capturing events. Defaults to the singleton
|
||||
* instance.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> SentryNavigation3Traced(
|
||||
@SuppressLint("ComposeUnstableCollections") backStack: List<T>,
|
||||
enableNavigationBreadcrumbs: Boolean = true,
|
||||
enableNavigationTracing: Boolean = true,
|
||||
keyToRoute: (T) -> String? = { it.toString() },
|
||||
scopes: IScopes = ScopesAdapter.getInstance(),
|
||||
) {
|
||||
val backStackSnapshot by rememberUpdatedState(backStack)
|
||||
val enableBreadcrumbsSnapshot by rememberUpdatedState(enableNavigationBreadcrumbs)
|
||||
val enableTracingSnapshot by rememberUpdatedState(enableNavigationTracing)
|
||||
val keyToRouteSnapshot by rememberUpdatedState(keyToRoute)
|
||||
val scopesSnapshot by rememberUpdatedState(scopes)
|
||||
|
||||
val transactionHolder = remember { NavigationTransactionHolder() }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
addIntegrationToSdkVersion("Navigation3")
|
||||
|
||||
val observer =
|
||||
SentryBackStackObserver(
|
||||
enableNavigationBreadcrumbs = enableBreadcrumbsSnapshot,
|
||||
enableNavigationTracing = enableTracingSnapshot,
|
||||
keyToRoute = keyToRouteSnapshot,
|
||||
scopes = scopesSnapshot,
|
||||
transactionHolder = transactionHolder,
|
||||
)
|
||||
|
||||
@SuppressLint("RawDispatchersUse")
|
||||
val scope =
|
||||
kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.Dispatchers.Main + kotlinx.coroutines.SupervisorJob()
|
||||
)
|
||||
@SuppressLint("DenyListedApi")
|
||||
val job =
|
||||
snapshotFlow { backStackSnapshot }
|
||||
.drop(1) // Skip initial state
|
||||
.onEach { currentStack ->
|
||||
val currentKey = currentStack.lastOrNull()
|
||||
val previousKey = observer.previousKey
|
||||
if (currentKey != null && currentKey != previousKey) {
|
||||
observer.handleNavigation(currentKey, currentStack)
|
||||
observer.previousKey = currentKey
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
onDispose { job.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal observer that monitors back stack changes and creates Sentry events. */
|
||||
internal class SentryBackStackObserver<T>(
|
||||
private val enableNavigationBreadcrumbs: Boolean,
|
||||
private val enableNavigationTracing: Boolean,
|
||||
private val keyToRoute: (T) -> String?,
|
||||
private val scopes: IScopes,
|
||||
private val transactionHolder: NavigationTransactionHolder,
|
||||
) {
|
||||
internal var previousKey: T? = null
|
||||
|
||||
private val isPerformanceEnabled: Boolean
|
||||
get() = scopes.options.isTracingEnabled && enableNavigationTracing
|
||||
|
||||
init {
|
||||
SentryIntegrationPackageStorage.getInstance()
|
||||
.addPackage("maven:io.sentry:sentry-android-navigation3", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
internal fun handleNavigation(currentKey: T, currentStack: List<T>) {
|
||||
val currentRoute = keyToRoute(currentKey)
|
||||
if (currentRoute != null) {
|
||||
addBreadcrumb(currentRoute, currentStack)
|
||||
|
||||
if (scopes.options.isEnableScreenTracking) {
|
||||
scopes.configureScope { it.screen = currentRoute }
|
||||
}
|
||||
|
||||
startTracing(currentRoute, currentStack)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addBreadcrumb(toRoute: String, currentStack: List<T>) {
|
||||
if (!enableNavigationBreadcrumbs) {
|
||||
return
|
||||
}
|
||||
|
||||
val breadcrumb =
|
||||
Breadcrumb().apply {
|
||||
type = NAVIGATION_OP
|
||||
category = NAVIGATION_OP
|
||||
|
||||
val fromKey = previousKey
|
||||
if (fromKey != null) {
|
||||
val fromRoute = keyToRoute(fromKey)
|
||||
if (fromRoute != null) {
|
||||
data["from"] = fromRoute
|
||||
}
|
||||
}
|
||||
|
||||
data["to"] = toRoute
|
||||
|
||||
// Capture back stack keys as a list
|
||||
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
|
||||
if (backStackKeys.isNotEmpty()) {
|
||||
data["back_stack"] = backStackKeys
|
||||
}
|
||||
|
||||
level = SentryLevel.INFO
|
||||
}
|
||||
|
||||
val hint = Hint()
|
||||
hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, toRoute)
|
||||
scopes.addBreadcrumb(breadcrumb, hint)
|
||||
}
|
||||
|
||||
private fun startTracing(routeName: String, currentStack: List<T>) {
|
||||
if (!isPerformanceEnabled) {
|
||||
io.sentry.util.TracingUtils.startNewTrace(scopes)
|
||||
return
|
||||
}
|
||||
|
||||
// Finish previous transaction in this navigation scope
|
||||
if (transactionHolder.activeTransaction != null) {
|
||||
stopTracing()
|
||||
}
|
||||
|
||||
val transactionOptions =
|
||||
TransactionOptions().also {
|
||||
it.isWaitForChildren = true
|
||||
it.idleTimeout = scopes.options.idleTimeout
|
||||
|
||||
// Set deadline timeout based on configured option
|
||||
val deadlineTimeoutMillis = scopes.options.deadlineTimeout
|
||||
// No deadline when zero or negative value is set
|
||||
it.deadlineTimeout = if (deadlineTimeoutMillis <= 0) null else deadlineTimeoutMillis
|
||||
|
||||
it.isTrimEnd = true
|
||||
}
|
||||
|
||||
val transaction =
|
||||
scopes.startTransaction(
|
||||
TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP),
|
||||
transactionOptions,
|
||||
)
|
||||
|
||||
transaction.spanContext.origin = TRACE_ORIGIN
|
||||
|
||||
// Capture back stack keys as data
|
||||
val backStackKeys = currentStack.mapNotNull { keyToRoute(it) }
|
||||
if (backStackKeys.isNotEmpty()) {
|
||||
transaction.setData("back_stack", backStackKeys)
|
||||
}
|
||||
|
||||
scopes.configureScope { scope ->
|
||||
scope.withTransaction { tx ->
|
||||
if (tx == null) {
|
||||
scope.transaction = transaction
|
||||
}
|
||||
}
|
||||
}
|
||||
transactionHolder.activeTransaction = transaction
|
||||
}
|
||||
|
||||
private fun stopTracing() {
|
||||
val status = transactionHolder.activeTransaction?.status ?: SpanStatus.OK
|
||||
transactionHolder.activeTransaction?.finish(status)
|
||||
|
||||
// clear transaction from scope so others can bind to it
|
||||
scopes.configureScope { scope ->
|
||||
scope.withTransaction { tx ->
|
||||
if (tx == transactionHolder.activeTransaction) {
|
||||
scope.clearTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transactionHolder.activeTransaction = null
|
||||
}
|
||||
}
|
||||
+143
-44
@@ -6,7 +6,9 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
@@ -33,19 +35,22 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.deliveryhero.whetstone.compose.injectedViewModel
|
||||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.android.ui.PostActions
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawAppBar
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
|
||||
@@ -55,36 +60,50 @@ import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
|
||||
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
|
||||
import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
|
||||
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
|
||||
import dev.msfjarvis.claw.android.ui.navigation.ClawBackStack
|
||||
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
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Settings
|
||||
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.urllauncher.UrlLauncher
|
||||
import dev.msfjarvis.claw.common.user.UserProfile
|
||||
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun LobstersPostsScreen(
|
||||
urlLauncher: UrlLauncher,
|
||||
uriHandler: UriHandler,
|
||||
windowSizeClass: WindowSizeClass,
|
||||
setWebUri: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ClawViewModel = injectedViewModel(),
|
||||
deepLinkDestination: NavKey? = null,
|
||||
clearDeepLink: () -> Unit = {},
|
||||
viewModel: ClawViewModel = metroViewModel(),
|
||||
settingsViewModel: SettingsViewModel = metroViewModel(),
|
||||
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
|
||||
) {
|
||||
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
|
||||
|
||||
val libraries by produceLibraries()
|
||||
val clawBackStack = remember { ClawBackStack(Hottest) }
|
||||
val backStack = rememberNavBackStack(Hottest)
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
|
||||
|
||||
// region Pain
|
||||
@@ -97,31 +116,27 @@ fun LobstersPostsScreen(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
|
||||
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
|
||||
val savedPosts by viewModel.savedPostsByMonth.collectAsStateWithLifecycle(persistentMapOf())
|
||||
val savedPostsCount by viewModel.savedPostsCount.collectAsStateWithLifecycle(0L)
|
||||
|
||||
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
|
||||
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(persistentSetOf())
|
||||
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
||||
val username by settingsViewModel.username.collectAsStateWithLifecycle(null)
|
||||
|
||||
val postIdOverride = activity?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY)
|
||||
LaunchedEffect(Unit) {
|
||||
if (postIdOverride != null) {
|
||||
clawBackStack.add(Comments(postIdOverride))
|
||||
LaunchedEffect(deepLinkDestination) {
|
||||
if (deepLinkDestination != null) {
|
||||
navigateTo(backStack, deepLinkDestination, allowStacking = true)
|
||||
clearDeepLink()
|
||||
}
|
||||
}
|
||||
|
||||
val navItems =
|
||||
persistentListOf(
|
||||
NavigationItem(AppDestinations.HOTTEST) {
|
||||
coroutineScope.launch {
|
||||
if (hottestPosts.itemCount > 0) hottestListState.animateScrollToItem(index = 0)
|
||||
}
|
||||
coroutineScope.launch { hottestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(AppDestinations.NEWEST) {
|
||||
coroutineScope.launch {
|
||||
if (newestPosts.itemCount > 0) newestListState.animateScrollToItem(index = 0)
|
||||
}
|
||||
coroutineScope.launch { newestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(AppDestinations.SAVED) {
|
||||
coroutineScope.launch { if (savedPosts.isNotEmpty()) savedListState.scrollToItem(0) }
|
||||
@@ -130,26 +145,30 @@ fun LobstersPostsScreen(
|
||||
// endregion
|
||||
|
||||
val postActions = remember {
|
||||
PostActions(context, urlLauncher, viewModel) { clawBackStack.add(Comments(it)) }
|
||||
PostActions(context, uriHandler, viewModel) {
|
||||
navigateTo(backStack, Comments(it), allowStacking = false)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size > 1) { popBackStack(backStack) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ClawAppBar(
|
||||
activity = activity,
|
||||
isTopLevel = clawBackStack.isOnTopLevelDestination(),
|
||||
navigateTo = { clawBackStack.add(it) },
|
||||
popBackStack = { clawBackStack.removeLastOrNull() },
|
||||
isTopLevel = backStack.lastOrNull() is TopLevelDestination,
|
||||
navigateTo = { destination -> navigateTo(backStack, destination) },
|
||||
popBackStack = { popBackStack(backStack) },
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
val currentDestination = clawBackStack.firstOrNull()
|
||||
val currentDestination = backStack.lastOrNull()
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
|
||||
ClawNavigationBar(
|
||||
items = navItems,
|
||||
currentNavKey = currentDestination,
|
||||
navigateTo = { clawBackStack.add(it) },
|
||||
isVisible = clawBackStack.isOnTopLevelDestination(),
|
||||
navigateTo = { destination -> navigateTo(backStack, destination) },
|
||||
isVisible = backStack.lastOrNull() is TopLevelDestination,
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
@@ -157,21 +176,27 @@ fun LobstersPostsScreen(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier.semantics { testTagsAsResourceId = true },
|
||||
) { contentPadding ->
|
||||
SentryNavigation3Traced(backStack = backStack)
|
||||
Row {
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) {
|
||||
val currentDestination = clawBackStack.firstOrNull()
|
||||
val currentDestination = backStack.lastOrNull()
|
||||
ClawNavigationRail(
|
||||
items = navItems,
|
||||
currentNavKey = currentDestination,
|
||||
navigateTo = { clawBackStack.add(it) },
|
||||
isVisible = clawBackStack.isOnTopLevelDestination(),
|
||||
navigateTo = { destination -> navigateTo(backStack, destination) },
|
||||
isVisible = backStack.lastOrNull() is TopLevelDestination,
|
||||
)
|
||||
}
|
||||
NavDisplay(
|
||||
backStack = clawBackStack.backStack,
|
||||
backStack = backStack,
|
||||
modifier = modifier.hazeSource(hazeState),
|
||||
sceneStrategy = listDetailStrategy,
|
||||
onBack = { clawBackStack.removeLastOrNull() },
|
||||
sceneStrategies = listOf(listDetailStrategy),
|
||||
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator()),
|
||||
onBack = {
|
||||
if (popBackStack(backStack) == null) {
|
||||
activity?.finish()
|
||||
}
|
||||
},
|
||||
predictivePopTransitionSpec = {
|
||||
slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(200)) togetherWith
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200))
|
||||
@@ -182,22 +207,26 @@ fun LobstersPostsScreen(
|
||||
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
|
||||
) {
|
||||
setWebUri("https://lobste.rs/")
|
||||
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
|
||||
NetworkPosts(
|
||||
lazyPagingItems = hottestPosts,
|
||||
listState = hottestListState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
filteredTags = filteredTags,
|
||||
)
|
||||
}
|
||||
entry<Newest>(
|
||||
metadata = ListDetailSceneStrategy.listPane(detailPlaceholder = { Placeholder() })
|
||||
) {
|
||||
setWebUri("https://lobste.rs/")
|
||||
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
|
||||
NetworkPosts(
|
||||
lazyPagingItems = newestPosts,
|
||||
listState = newestListState,
|
||||
postActions = postActions,
|
||||
contentPadding = contentPadding,
|
||||
filteredTags = filteredTags,
|
||||
)
|
||||
}
|
||||
entry<Saved>(
|
||||
@@ -212,11 +241,28 @@ fun LobstersPostsScreen(
|
||||
)
|
||||
}
|
||||
entry<Comments>(metadata = ListDetailSceneStrategy.detailPane()) { dest ->
|
||||
val commentsPostActions = remember {
|
||||
PostActions(context, uriHandler, viewModel) {
|
||||
navigateTo(backStack, Comments(it), allowStacking = true)
|
||||
}
|
||||
}
|
||||
CommentsPage(
|
||||
postId = dest.postId,
|
||||
postActions = postActions,
|
||||
postActions = commentsPostActions,
|
||||
contentPadding = contentPadding,
|
||||
openUserProfile = { clawBackStack.add(User(it)) },
|
||||
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>(
|
||||
@@ -237,17 +283,22 @@ fun LobstersPostsScreen(
|
||||
UserProfile(
|
||||
username = dest.username,
|
||||
contentPadding = contentPadding,
|
||||
openUserProfile = { clawBackStack.add(User(it)) },
|
||||
openUserProfile = { navigateTo(backStack, User(it)) },
|
||||
)
|
||||
}
|
||||
entry<Settings>(metadata = ListDetailSceneStrategy.extraPane()) {
|
||||
SettingsScreen(
|
||||
openInputStream = context.contentResolver::openInputStream,
|
||||
openOutputStream = context.contentResolver::openOutputStream,
|
||||
openLibrariesScreen = { clawBackStack.add(AboutLibraries) },
|
||||
openLibrariesScreen = { navigateTo(backStack, AboutLibraries) },
|
||||
openRepository = {
|
||||
urlLauncher.openUri("https://github.com/msfjarvis/compose-lobsters")
|
||||
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,
|
||||
@@ -257,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(
|
||||
@@ -279,7 +333,52 @@ fun LobstersPostsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
fun navigateTo(
|
||||
backStack: MutableList<NavKey>,
|
||||
destination: NavKey,
|
||||
allowStacking: Boolean = false,
|
||||
) {
|
||||
if (destination is TopLevelDestination) {
|
||||
backStack.clear()
|
||||
if (destination != Hottest) {
|
||||
backStack.add(Hottest)
|
||||
}
|
||||
backStack.add(destination)
|
||||
return
|
||||
}
|
||||
|
||||
if (destination is NonStackable) {
|
||||
if (allowStacking && destination is Comments) {
|
||||
val lastItem = backStack.lastOrNull()
|
||||
if (lastItem is Comments && lastItem.postId == destination.postId) {
|
||||
return
|
||||
}
|
||||
backStack.add(destination)
|
||||
return
|
||||
}
|
||||
|
||||
val existingEntry = backStack.firstOrNull {
|
||||
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
|
||||
}
|
||||
|
||||
if (existingEntry != null) {
|
||||
backStack.remove(existingEntry)
|
||||
}
|
||||
}
|
||||
|
||||
backStack.add(destination)
|
||||
}
|
||||
|
||||
private fun popBackStack(backStack: MutableList<NavKey>): NavKey? {
|
||||
if (backStack.size <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Placeholder(modifier: Modifier = Modifier) {
|
||||
Text(text = "Placeholder", modifier = modifier)
|
||||
Text(text = stringResource(R.string.no_post_selected), modifier = modifier)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,29 @@ package dev.msfjarvis.claw.android.ui.screens
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.deliveryhero.whetstone.compose.injectedViewModel
|
||||
import dev.msfjarvis.claw.android.ui.lists.SearchList
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.tags.TagFilterViewModel
|
||||
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
postActions: PostActions,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ClawViewModel = injectedViewModel(),
|
||||
viewModel: ClawViewModel = metroViewModel(),
|
||||
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val searchResults = viewModel.searchResults.collectAsLazyPagingItems()
|
||||
val filteredTags by tagFilterViewModel.filteredTags.collectAsStateWithLifecycle(emptySet())
|
||||
|
||||
SearchList(
|
||||
lazyPagingItems = searchResults,
|
||||
listState = listState,
|
||||
@@ -32,6 +39,7 @@ fun SearchScreen(
|
||||
searchQuery = viewModel.searchQuery,
|
||||
contentPadding = contentPadding,
|
||||
setSearchQuery = { query -> viewModel.searchQuery = query },
|
||||
filteredTags = filteredTags.toPersistentSet(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ 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
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -38,10 +40,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
import io.sentry.Sentry
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -50,14 +55,27 @@ 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?,
|
||||
importPosts: suspend (InputStream) -> Unit,
|
||||
importPosts: suspend (InputStream) -> Result<Unit>,
|
||||
exportPostsAsJson: suspend (OutputStream) -> Unit,
|
||||
exportPostsAsHtml: suspend (OutputStream) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
@@ -66,11 +84,42 @@ 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 = "Data Management")
|
||||
SectionHeader(title = stringResource(R.string.data_management))
|
||||
ListItem(
|
||||
headlineContent = { Text("Data transfer") },
|
||||
supportingContent = { Text("Import and export your saved posts") },
|
||||
headlineContent = { Text(stringResource(R.string.data_transfer)) },
|
||||
supportingContent = { Text(stringResource(R.string.import_and_export_your_saved_posts)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
@@ -95,10 +144,18 @@ fun SettingsScreen(
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// App Information Section
|
||||
SectionHeader(title = "App Information")
|
||||
SectionHeader(title = stringResource(R.string.app_information))
|
||||
ListItem(
|
||||
headlineContent = { Text("Version") },
|
||||
supportingContent = { Text("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") },
|
||||
headlineContent = { Text(stringResource(R.string.version)) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.version_placeholder,
|
||||
BuildConfig.VERSION_NAME,
|
||||
BuildConfig.VERSION_CODE,
|
||||
)
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
@@ -108,8 +165,8 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Saved posts") },
|
||||
supportingContent = { Text("$savedPostsCount posts saved locally") },
|
||||
headlineContent = { Text(stringResource(R.string.saved_posts)) },
|
||||
supportingContent = { Text(stringResource(R.string.posts_saved_locally, savedPostsCount)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmarks,
|
||||
@@ -118,14 +175,28 @@ fun SettingsScreen(
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.tag_filtering)) },
|
||||
supportingContent = { Text(stringResource(R.string.filter_posts_by_tags)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = openTagFiltering),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// About Section
|
||||
SectionHeader(title = "About")
|
||||
SectionHeader(title = stringResource(R.string.about))
|
||||
ListItem(
|
||||
headlineContent = { Text("Libraries") },
|
||||
supportingContent = { Text("View open source libraries used in this app") },
|
||||
headlineContent = { Text(stringResource(R.string.libraries)) },
|
||||
supportingContent = {
|
||||
Text(stringResource(R.string.view_open_source_libraries_used_in_this_))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
|
||||
@@ -136,8 +207,8 @@ fun SettingsScreen(
|
||||
modifier = Modifier.clickable(onClick = openLibrariesScreen),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Source code") },
|
||||
supportingContent = { Text("View the source code on GitHub") },
|
||||
headlineContent = { Text(stringResource(R.string.source_code)) },
|
||||
supportingContent = { Text(stringResource(R.string.view_the_source_code_on_github)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
@@ -171,7 +242,7 @@ private fun ImportPosts(
|
||||
openInputStream: (Uri) -> InputStream?,
|
||||
coroutineScope: CoroutineScope,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
importPosts: suspend (InputStream) -> Unit,
|
||||
importPosts: suspend (InputStream) -> Result<Unit>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val importAction =
|
||||
@@ -181,18 +252,29 @@ private fun ImportPosts(
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
openInputStream(uri)?.use { stream ->
|
||||
importPosts(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
|
||||
try {
|
||||
openInputStream(uri)?.use { stream ->
|
||||
val result = importPosts(stream)
|
||||
if (result.isSuccess) {
|
||||
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
|
||||
} else {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to import posts")
|
||||
}
|
||||
} ?: snackbarHostState.showSnackbarDismissing("Unable to open selected file")
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to open file: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { importAction.launch(JSON_MIME_TYPE) },
|
||||
onClick = {
|
||||
importAction.launch(JSON_MIME_TYPE)
|
||||
Sentry.metrics().count("import", 1.0)
|
||||
},
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = "Import")
|
||||
Text(text = stringResource(R.string.import_action))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,9 +295,19 @@ private inline fun ExportPosts(
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsJson(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
try {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsJson(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
Sentry.metrics().count("json_export_success", 1.0)
|
||||
}
|
||||
?: run {
|
||||
snackbarHostState.showSnackbarDismissing("Unable to open output file")
|
||||
Sentry.metrics().count("json_export_failure", 1.0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
|
||||
Sentry.metrics().count("json_export_failure", 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,9 +319,19 @@ private inline fun ExportPosts(
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsHtml(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
try {
|
||||
openOutputStream(uri)?.use { stream ->
|
||||
exportPostsAsHtml(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
Sentry.metrics().count("html_export_success", 1.0)
|
||||
}
|
||||
?: run {
|
||||
snackbarHostState.showSnackbarDismissing("Unable to open output file")
|
||||
Sentry.metrics().count("html_export_failure", 1.0)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbarDismissing("Failed to export posts: ${e.message}")
|
||||
Sentry.metrics().count("html_export_failure", 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,20 +341,23 @@ private inline fun ExportPosts(
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(text = "Export")
|
||||
Text(text = stringResource(R.string.export))
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("JSON") },
|
||||
text = { Text(stringResource(R.string.json)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
jsonExportAction.launch("claw-export.json")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Filled.Code, contentDescription = "Export as JSON")
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.export_as_json),
|
||||
)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Bookmarks") },
|
||||
text = { Text(stringResource(R.string.bookmarks)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
htmlExportAction.launch("claw-export.html")
|
||||
@@ -260,7 +365,7 @@ private inline fun ExportPosts(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmarks,
|
||||
contentDescription = "Export as browser bookmarks",
|
||||
contentDescription = stringResource(R.string.export_as_browser_bookmarks),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -281,10 +386,15 @@ private fun SettingsScreenPreview() {
|
||||
SettingsScreen(
|
||||
openLibrariesScreen = {},
|
||||
openRepository = {},
|
||||
openTagFiltering = {},
|
||||
openLoginScreen = {},
|
||||
onLogout = {},
|
||||
isLoggedIn = false,
|
||||
username = null,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
openInputStream = { null },
|
||||
openOutputStream = { null },
|
||||
importPosts = {},
|
||||
importPosts = { Result.success(Unit) },
|
||||
exportPostsAsJson = {},
|
||||
exportPostsAsHtml = {},
|
||||
contentPadding = PaddingValues(),
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.util.Log
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePost
|
||||
import dev.msfjarvis.claw.database.local.CachedRemotePostQueries
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class CachedRemotePostsRepository(
|
||||
private val cachedRemotePostQueries: CachedRemotePostQueries,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val cachedPosts = cachedRemotePostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getRecentPosts(limit: Long) =
|
||||
cachedRemotePostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
|
||||
|
||||
suspend fun getCachedPosts(): List<CachedRemotePost> {
|
||||
return withContext(readDispatcher) { cachedRemotePostQueries.selectAllPosts().executeAsList() }
|
||||
}
|
||||
|
||||
suspend fun savePosts(posts: List<CachedRemotePost>) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Caching remote posts: ${posts.joinToString(",") { it.shortId }}")
|
||||
}
|
||||
withContext(writeDispatcher) {
|
||||
cachedRemotePostQueries.transaction {
|
||||
cachedRemotePostQueries.deleteAllPosts()
|
||||
posts.forEach { post -> cachedRemotePostQueries.insertOrReplacePost(post) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "CachedRemotePostsRepository"
|
||||
}
|
||||
}
|
||||
@@ -15,41 +15,48 @@ import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.deliveryhero.whetstone.viewmodel.ContributesViewModel
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
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
|
||||
import dev.msfjarvis.claw.model.fromSavedPost
|
||||
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 java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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
|
||||
|
||||
@ContributesViewModel
|
||||
class ClawViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@ViewModelKey
|
||||
@ContributesIntoMap(scope = AppScope::class, binding = binding<ViewModel>())
|
||||
class ClawViewModel(
|
||||
context: Context,
|
||||
private val api: LobstersApi,
|
||||
private val readPostsRepository: ReadPostsRepository,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
@@ -57,26 +64,45 @@ constructor(
|
||||
private val dataTransferRepository: DataTransferRepository,
|
||||
private val pagingSourceFactory: LobstersPagingSource.Factory,
|
||||
private val searchPagingSourceFactory: SearchPagingSource.Factory,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
@ForScope(ApplicationScope::class) context: Context,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@param:MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
) : AndroidViewModel(context as Application) {
|
||||
val hottestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = { pagingSourceFactory.create(api::getHottestPosts) },
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
val newestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = { pagingSourceFactory.create(api::getNewestPosts) },
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
private var _hottestPosts: Flow<PagingData<UIPost>>? = null
|
||||
val hottestPosts: Flow<PagingData<UIPost>>
|
||||
get() {
|
||||
if (_hottestPosts == null) {
|
||||
_hottestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = {
|
||||
pagingSourceFactory.create { page -> api.getHottestPosts(page).toPostsResult() }
|
||||
},
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
return requireNotNull(_hottestPosts) { "_hottestPosts should be initialized here" }
|
||||
}
|
||||
|
||||
private var _newestPosts: Flow<PagingData<UIPost>>? = null
|
||||
val newestPosts: Flow<PagingData<UIPost>>
|
||||
get() {
|
||||
if (_newestPosts == null) {
|
||||
_newestPosts =
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||
initialKey = STARTING_PAGE_INDEX,
|
||||
pagingSourceFactory = {
|
||||
pagingSourceFactory.create { page -> api.getNewestPosts(page).toPostsResult() }
|
||||
},
|
||||
)
|
||||
.flow
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
return requireNotNull(_newestPosts) { "_newestPosts should be initialized here" }
|
||||
}
|
||||
|
||||
val searchResults =
|
||||
Pager(
|
||||
PagingConfig(pageSize = PAGE_SIZE),
|
||||
@@ -85,45 +111,37 @@ constructor(
|
||||
)
|
||||
.flow
|
||||
val savedPosts = savedPostsRepository.savedPosts.map { it.map(UIPost.Companion::fromSavedPost) }
|
||||
val savedPostsCount = savedPosts.map { it.size.toLong() }
|
||||
val savedPostsCount = savedPostsRepository.savedPosts.map { it.size.toLong() }
|
||||
val savedPostsByMonth
|
||||
get() = savedPosts.map(::groupSavedPosts)
|
||||
get() =
|
||||
savedPostsRepository.savedPostsSortedByDate.map { posts ->
|
||||
posts
|
||||
.map(UIPost.Companion::fromSavedPost)
|
||||
.groupBy { post ->
|
||||
val time = post.createdAt.toLocalDateTime()
|
||||
"${time.month.name.lowercase().capitalize(Locale.current)} ${time.year}"
|
||||
}
|
||||
.toImmutableMap()
|
||||
}
|
||||
|
||||
var searchQuery by mutableStateOf("")
|
||||
|
||||
private var _readPosts = emptyList<String>()
|
||||
private var _savedPosts = emptyList<String>()
|
||||
private var _readPosts by mutableStateOf(emptySet<String>())
|
||||
private var _savedPosts by mutableStateOf(emptySet<String>())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { savedPosts.collectLatest { _savedPosts = it.map(UIPost::shortId) } }
|
||||
viewModelScope.launch { readPostsRepository.readPosts.collectLatest { _readPosts = it } }
|
||||
}
|
||||
|
||||
private fun groupSavedPosts(items: List<UIPost>): ImmutableMap<String, List<UIPost>> {
|
||||
val sorted =
|
||||
items.sortedWith { post1, post2 ->
|
||||
val post1Date = post1.createdAt.toLocalDateTime()
|
||||
val post2Date = post2.createdAt.toLocalDateTime()
|
||||
if (post2Date.isBefore(post1Date)) {
|
||||
-1
|
||||
} else if (post2Date.isAfter(post1Date)) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
return sorted
|
||||
.groupBy { post ->
|
||||
val time = post.createdAt.toLocalDateTime()
|
||||
"${time.month.name.lowercase().capitalize(Locale.current)} ${time.year}"
|
||||
}
|
||||
.toImmutableMap()
|
||||
viewModelScope.launch {
|
||||
savedPosts.collectLatest { _savedPosts = it.map(UIPost::shortId).toSet() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
readPostsRepository.readPosts.collectLatest { _readPosts = it.toSet() }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSave(post: UIPost) {
|
||||
viewModelScope.launch {
|
||||
savedPostsRepository.toggleSave(post)
|
||||
withContext(mainDispatcher) { SavedPostsWidget(savedPosts).updateAll(getApplication()) }
|
||||
withContext(mainDispatcher) { SavedPostsWidget().updateAll(getApplication()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +156,8 @@ constructor(
|
||||
suspend fun getLinkMetadata(url: String) =
|
||||
withContext(ioDispatcher) { linkMetadataRepository.getLinkMetadata(url) }
|
||||
|
||||
suspend fun importPosts(input: InputStream) = dataTransferRepository.importPosts(input)
|
||||
suspend fun importPosts(input: InputStream): Result<Unit> =
|
||||
dataTransferRepository.importPosts(input)
|
||||
|
||||
suspend fun exportPostsAsJson(output: OutputStream) =
|
||||
dataTransferRepository.exportPostsAsJson(output)
|
||||
@@ -156,7 +175,7 @@ constructor(
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
|
||||
+17
-21
@@ -9,11 +9,10 @@ package dev.msfjarvis.claw.android.viewmodel
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.database.SavedPostSerializer
|
||||
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 javax.inject.Inject
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -23,20 +22,24 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class DataTransferRepository
|
||||
@Inject
|
||||
constructor(
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class DataTransferRepository(
|
||||
private val json: Json,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val serializer = ListSerializer(SavedPostSerializer)
|
||||
|
||||
suspend fun importPosts(input: InputStream) {
|
||||
val posts: List<SavedPost> =
|
||||
withContext(ioDispatcher) { json.decodeFromStream(serializer, input) }
|
||||
savedPostsRepository.savePosts(posts)
|
||||
suspend fun importPosts(input: InputStream): Result<Unit> {
|
||||
return try {
|
||||
val posts: List<SavedPost> =
|
||||
withContext(ioDispatcher) { json.decodeFromStream(serializer, input) }
|
||||
savedPostsRepository.savePosts(posts)
|
||||
Result.success(Unit)
|
||||
} catch (t: Throwable) {
|
||||
Result.failure(t)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportPostsAsJson(output: OutputStream) {
|
||||
@@ -45,11 +48,8 @@ constructor(
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -84,10 +84,6 @@ constructor(
|
||||
)
|
||||
append("</DD></p>\n")
|
||||
}
|
||||
withContext(ioDispatcher) {
|
||||
val writer = output.bufferedWriter()
|
||||
writer.write(html)
|
||||
writer.flush()
|
||||
}
|
||||
withContext(ioDispatcher) { output.bufferedWriter().use { writer -> writer.write(html) } }
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -8,16 +8,15 @@ package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.coroutines.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.saket.unfurl.Unfurler
|
||||
|
||||
class LinkMetadataRepository
|
||||
@Inject
|
||||
constructor(
|
||||
class LinkMetadataRepository(
|
||||
private val unfurler: Unfurler,
|
||||
@IODispatcher private val dispatcher: CoroutineDispatcher,
|
||||
@param:IODispatcher private val dispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
suspend fun getLinkMetadata(url: String): LinkMetadata {
|
||||
val result = withContext(dispatcher) { unfurler.unfurl(url) }
|
||||
|
||||
@@ -11,16 +11,15 @@ import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.ReadPostsQueries
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ReadPostsRepository
|
||||
@Inject
|
||||
constructor(
|
||||
class ReadPostsRepository(
|
||||
private val readPostsQueries: ReadPostsQueries,
|
||||
@DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val readPosts = readPostsQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
|
||||
|
||||
+26
-11
@@ -6,41 +6,56 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.android.BuildConfig
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import io.github.aakira.napier.Napier
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SavedPostsRepository
|
||||
@Inject
|
||||
constructor(
|
||||
class SavedPostsRepository(
|
||||
private val savedPostQueries: SavedPostQueries,
|
||||
@DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
|
||||
val savedPostsSortedByDate =
|
||||
savedPostQueries.selectAllPostsSortedByDate().asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getPostsFromLastNDays(days: Long) =
|
||||
savedPostQueries.selectPostsFromLastNDays(days.toString()).asFlow().mapToList(readDispatcher)
|
||||
|
||||
fun getRecentPosts(limit: Long) =
|
||||
savedPostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
|
||||
|
||||
suspend fun toggleSave(post: UIPost) {
|
||||
if (savedPosts.firstOrNull().orEmpty().any { it.shortId == post.shortId }) {
|
||||
Napier.d(tag = TAG) { "Removing post: ${post.shortId}" }
|
||||
val exists =
|
||||
withContext(readDispatcher) { savedPostQueries.postExists(post.shortId).executeAsOne() }
|
||||
if (exists) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Removing post: ${post.shortId}")
|
||||
}
|
||||
withContext(writeDispatcher) { savedPostQueries.deletePost(post.shortId) }
|
||||
} else {
|
||||
Napier.d(tag = TAG) { "Saving post: ${post.shortId}" }
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Saving post: ${post.shortId}")
|
||||
}
|
||||
withContext(writeDispatcher) { savedPostQueries.insertOrReplacePost(post.toSavedPost()) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun savePosts(posts: List<SavedPost>) {
|
||||
Napier.d(tag = TAG) { "Saving posts: ${posts.joinToString(",") { it.shortId }}" }
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "Saving posts: ${posts.joinToString(",") { it.shortId }}")
|
||||
}
|
||||
withContext(writeDispatcher) {
|
||||
savedPostQueries.transaction {
|
||||
posts.forEach { post -> savedPostQueries.insertOrReplacePost(post) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -10,68 +10,81 @@ import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.deliveryhero.whetstone.worker.ContributesWorker
|
||||
import com.deliveryhero.whetstone.worker.WorkerScope
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.model.UIPost
|
||||
import dev.msfjarvis.claw.model.fromSavedPost
|
||||
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.core.injection.WorkerKey
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
import kotlin.random.Random
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* WorkManager-backed [CoroutineWorker] that gets all the posts from [SavedPostsRepository], fetches
|
||||
* their newest state using the [LobstersApi] and then writes them back to the database. This allows
|
||||
* saved posts that were saved before comment counts were added to be able to show a comment count
|
||||
* and for new-enough posts that are still getting comments to have an accurate one.
|
||||
* WorkManager-backed [CoroutineWorker] that gets all the posts from [SavedPostsRepository] that
|
||||
* were created in the last 30 days, fetches their newest state using the [LobstersApi] and then
|
||||
* writes them back to the database. API calls are staggered with a configurable delay to avoid rate
|
||||
* limiting. This allows saved posts to stay up-to-date with current comment counts and other
|
||||
* metadata changes.
|
||||
*/
|
||||
@ContributesWorker
|
||||
class SavedPostUpdaterWorker
|
||||
@Inject
|
||||
constructor(
|
||||
@ForScope(WorkerScope::class) appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
@AssistedInject
|
||||
class SavedPostUpdaterWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
private val lobstersApi: LobstersApi,
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val postsToUpdate =
|
||||
savedPostsRepository.savedPosts
|
||||
.first()
|
||||
.sortedBy { post ->
|
||||
Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(post.createdAt))
|
||||
}
|
||||
.take(30)
|
||||
val postsToUpdate = savedPostsRepository.getPostsFromLastNDays(DAYS_TO_UPDATE).first()
|
||||
|
||||
val updatedPosts = mutableListOf<dev.msfjarvis.claw.database.local.SavedPost>()
|
||||
if (postsToUpdate.isEmpty()) {
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
for (post in postsToUpdate) {
|
||||
val updatedPosts = mutableListOf<SavedPost>()
|
||||
|
||||
for ((index, post) in postsToUpdate.withIndex()) {
|
||||
when (val result = lobstersApi.getPostDetails(post.shortId)) {
|
||||
is Success -> {
|
||||
updatedPosts.add(result.value.toSavedPost())
|
||||
}
|
||||
else -> {
|
||||
// Continue to the next post if the API call fails
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (index < postsToUpdate.lastIndex) {
|
||||
// Add a random delay between API calls to avoid rate limiting
|
||||
val delayMs = Random.nextLong(MIN_DELAY_MS, MAX_DELAY_MS)
|
||||
delay(delayMs)
|
||||
}
|
||||
// Add a 1-second delay to avoid rate-limiting
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
savedPostsRepository.savePosts(updatedPosts)
|
||||
if (updatedPosts.isNotEmpty()) {
|
||||
savedPostsRepository.savePosts(updatedPosts)
|
||||
SavedPostsWidget().updateAll(applicationContext)
|
||||
}
|
||||
|
||||
SavedPostsWidget(
|
||||
savedPostsRepository.savedPosts.map { it.map(UIPost.Companion::fromSavedPost) }
|
||||
)
|
||||
.updateAll(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DAYS_TO_UPDATE = 30L
|
||||
const val MIN_DELAY_MS = 500L
|
||||
const val MAX_DELAY_MS = 2000L
|
||||
}
|
||||
|
||||
@WorkerKey(SavedPostUpdaterWorker::class)
|
||||
@ContributesIntoMap(
|
||||
AppScope::class,
|
||||
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
|
||||
)
|
||||
@AssistedFactory
|
||||
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<SavedPostUpdaterWorker>
|
||||
}
|
||||
|
||||
@@ -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.android.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.core.injection.WorkerKey
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesIntoMap
|
||||
import dev.zacsweers.metro.binding
|
||||
|
||||
@AssistedInject
|
||||
class TagExpirationCleanupWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val tagBlockRepository: TagBlockRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
tagBlockRepository.removeExpiredTags()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@WorkerKey(TagExpirationCleanupWorker::class)
|
||||
@ContributesIntoMap(
|
||||
AppScope::class,
|
||||
binding = binding<InjectedWorkerFactory.WorkerInstanceFactory<*>>(),
|
||||
)
|
||||
@AssistedFactory
|
||||
abstract class Factory : InjectedWorkerFactory.WorkerInstanceFactory<TagExpirationCleanupWorker>
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+1
-2
@@ -9,8 +9,7 @@
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?android:colorControlNormal"
|
||||
android:alpha="0.6"
|
||||
android:tint="?android:attr/colorControlNormal"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
@@ -4,6 +4,41 @@
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name">Claw</string>
|
||||
<string name="saved_posts_widget_description">View your saved Lobsters posts</string>
|
||||
<string name="hottest_posts_widget_description">View the hottest posts on Lobsters</string>
|
||||
<string name="saved_posts">Saved posts</string>
|
||||
<string name="see_more_posts">See more posts</string>
|
||||
<plurals name="comments">
|
||||
<item quantity="one">%1$d comment</item>
|
||||
<item quantity="other">%1$d comments</item>
|
||||
</plurals>
|
||||
<string name="no_post_selected">No post selected</string>
|
||||
<string name="data_management">Data Management</string>
|
||||
<string name="data_transfer">Data transfer</string>
|
||||
<string name="import_and_export_your_saved_posts">Import and export your saved posts</string>
|
||||
<string name="app_information">App Information</string>
|
||||
<string name="version">Version</string>
|
||||
<string name="version_placeholder">%1$s (%2$s)</string>
|
||||
<string name="posts_saved_locally">%1$s posts saved locally</string>
|
||||
<string name="tag_filtering">Tag filtering</string>
|
||||
<string name="filter_posts_by_tags">Filter posts by tags</string>
|
||||
<string name="about">About</string>
|
||||
<string name="libraries">Libraries</string>
|
||||
<string name="view_open_source_libraries_used_in_this_">View open source libraries used in this app</string>
|
||||
<string name="source_code">Source code</string>
|
||||
<string name="view_the_source_code_on_github">View the source code on GitHub</string>
|
||||
<string name="import_action">Import</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="json">JSON</string>
|
||||
<string name="export_as_json">Export as JSON</string>
|
||||
<string name="bookmarks">Bookmarks</string>
|
||||
<string name="export_as_browser_bookmarks">Export as browser bookmarks</string>
|
||||
<string name="go_back_to_previous_screen">Go back to previous screen</string>
|
||||
<string name="the_app_icon_for_claw">The app icon for Claw</string>
|
||||
<string name="search_posts">Search posts</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="empty_inbox_icon">Empty inbox icon</string>
|
||||
<string name="no_saved_posts">No saved posts</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<cloud-backup>
|
||||
<include
|
||||
domain="database"
|
||||
path="databases" />
|
||||
path="." />
|
||||
</cloud-backup>
|
||||
|
||||
<device-transfer>
|
||||
<include
|
||||
domain="database"
|
||||
path="databases" />
|
||||
path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="database"
|
||||
path="databases" />
|
||||
path="." />
|
||||
</full-backup-content>
|
||||
|
||||
@@ -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.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/hottest_posts_widget_description"
|
||||
android:previewImage="@drawable/hottest_posts_widget_preview"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="349dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="349dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="vertical|horizontal"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -5,6 +5,8 @@
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/saved_posts_widget_description"
|
||||
android:previewImage="@drawable/saved_posts_widget_preview"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="349dp"
|
||||
android:minHeight="110dp"
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 androidx.navigation3.runtime.NavKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Comments
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Hottest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Newest
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Saved
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BackStackBehaviorTest {
|
||||
|
||||
private fun makeBackStack(vararg keys: NavKey) = mutableListOf(*keys)
|
||||
|
||||
@Test
|
||||
fun `navigating from between top level destinations prevents stacking`() {
|
||||
val backStack = makeBackStack(Hottest)
|
||||
|
||||
navigateTo(backStack, Newest)
|
||||
|
||||
assertThat(backStack).containsExactly(Hottest, Newest).inOrder()
|
||||
|
||||
navigateTo(backStack, Saved)
|
||||
|
||||
assertThat(backStack).containsExactly(Hottest, Saved).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NonStackable destinations do not stack by default`() {
|
||||
val backStack = makeBackStack(Hottest, Comments("abc123"))
|
||||
|
||||
navigateTo(backStack, Comments("def456"))
|
||||
|
||||
assertThat(backStack).containsExactly(Hottest, Comments("def456")).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NonStackable destinations stack when allowStacking = true`() {
|
||||
val backStack = makeBackStack(Hottest, Comments("abc123"))
|
||||
|
||||
navigateTo(backStack, Comments("def456"), allowStacking = true)
|
||||
|
||||
assertThat(backStack).containsExactly(Hottest, Comments("abc123"), Comments("def456")).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Same destination cannot be stacked on itself`() {
|
||||
val backStack = makeBackStack(Hottest, Comments("abc123"))
|
||||
|
||||
navigateTo(backStack, Comments("abc123"), allowStacking = true)
|
||||
|
||||
assertThat(backStack).containsExactly(Hottest, Comments("abc123")).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Same destination can be stacked with a gap in between`() {
|
||||
val backStack = makeBackStack(Hottest, Comments("abc123"), Comments("def456"))
|
||||
|
||||
navigateTo(backStack, Comments("abc123"), allowStacking = true)
|
||||
|
||||
assertThat(backStack)
|
||||
.containsExactly(Hottest, Comments("abc123"), Comments("def456"), Comments("abc123"))
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
@@ -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=15700
|
||||
versioning-plugin.versionName=1.57.0
|
||||
versioning-plugin.versionCode=16600
|
||||
versioning-plugin.versionName=1.66.0
|
||||
|
||||
+9
-13
@@ -7,31 +7,27 @@
|
||||
import dev.msfjarvis.claw.gradle.addTestDependencies
|
||||
|
||||
plugins {
|
||||
id("dev.msfjarvis.claw.android-library")
|
||||
id("dev.msfjarvis.claw.kotlin-android")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.whetstone)
|
||||
id("dev.msfjarvis.claw.kotlin-jvm")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.dependencyAnalysis)
|
||||
alias(libs.plugins.metro)
|
||||
}
|
||||
|
||||
android { namespace = "dev.msfjarvis.claw.api" }
|
||||
|
||||
anvil { generateDaggerFactories.set(true) }
|
||||
|
||||
dependencies {
|
||||
api(libs.dagger)
|
||||
api(libs.eithernet)
|
||||
api(libs.javax.inject)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.12.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.12.0-alpha02)" variant="all" version="8.12.0-alpha02">
|
||||
<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>
|
||||
|
||||
@@ -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,21 +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> {
|
||||
val elements =
|
||||
Jsoup.parse(value.string(), LobstersApi.BASE_URL).select("div.story_liner.h-entry")
|
||||
return elements.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 {
|
||||
@@ -31,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,24 +6,32 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.api.injection
|
||||
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.fleeksoft.ksoup.Ksoup
|
||||
import com.slack.eithernet.integration.retrofit.ApiResultCallAdapterFactory
|
||||
import com.slack.eithernet.integration.retrofit.ApiResultConverterFactory
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
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 javax.inject.Named
|
||||
import javax.inject.Qualifier
|
||||
import dev.msfjarvis.claw.api.converters.UnitConverter
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.IntKey
|
||||
import dev.zacsweers.metro.IntoMap
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -33,14 +41,15 @@ import retrofit2.create
|
||||
* uses [IntoMap] with [IntKey]s to fake the presence of a fixed order by sorting on the key of the
|
||||
* injected [Map]s when injecting them into [Retrofit].
|
||||
*/
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
@BindingContainer
|
||||
@ContributesTo(AppScope::class)
|
||||
object RetrofitModule {
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
converterFactories: Map<Int, @JvmSuppressWildcards Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
|
||||
converterFactories: Map<Int, Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, CallAdapter.Factory>,
|
||||
@Named("LobstersURL") baseUrl: String,
|
||||
): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
@@ -53,10 +62,11 @@ object RetrofitModule {
|
||||
|
||||
@Provides
|
||||
@SearchApi
|
||||
@SingleIn(AppScope::class)
|
||||
fun provideSearchApiRetrofit(
|
||||
client: OkHttpClient,
|
||||
@SearchApi converterFactories: List<@JvmSuppressWildcards Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
|
||||
@SearchApi converterFactories: List<Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, CallAdapter.Factory>,
|
||||
@Named("LobstersURL") baseUrl: String,
|
||||
): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
@@ -68,9 +78,17 @@ object RetrofitModule {
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides fun provideApi(retrofit: Retrofit): LobstersApi = retrofit.create()
|
||||
@Provides
|
||||
@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()
|
||||
|
||||
@Provides
|
||||
@@ -78,19 +96,40 @@ 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<@JvmSuppressWildcards Converter.Factory> =
|
||||
fun provideConverters(): List<Converter.Factory> =
|
||||
listOf(ApiResultConverterFactory, SearchConverter.Factory)
|
||||
|
||||
@Provides @Named("LobstersURL") fun provideLobstersUrl(): String = LobstersApi.BASE_URL
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user