mirror of
https://github.com/Athou/commafeed.git
synced 2026-11-03 19:05:03 +00:00
Compare commits
293 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae707a2602 | ||
|
|
69cf674a02 | ||
|
|
29987a460d | ||
|
|
fc8cd9c0a2 | ||
|
|
44c2893fff | ||
|
|
2e66fd4702 | ||
|
|
01ac8d6ebd | ||
|
|
ddff408afe | ||
|
|
0c280c4dc4 | ||
|
|
ca4642f7c9 | ||
|
|
93edb47dca | ||
|
|
801680e373 | ||
|
|
5704e1e1fa | ||
|
|
a2a6720cd1 | ||
|
|
4f8cddeb54 | ||
|
|
90d0846bad | ||
|
|
698b2f4dfd | ||
|
|
0053c54ae4 | ||
|
|
16b6077496 | ||
|
|
edb4d5e178 | ||
|
|
4b091fb90b | ||
|
|
30c049d10a | ||
|
|
fa8a447787 | ||
|
|
20ec5d3eb9 | ||
|
|
796dc88c33 | ||
|
|
f979f5426f | ||
|
|
8f9be28002 | ||
|
|
89845681be | ||
|
|
8fdf63dd73 | ||
|
|
8f07454270 | ||
|
|
946d82d8ee | ||
|
|
f8d625b651 | ||
|
|
eefdd40a02 | ||
|
|
08f2889f1d | ||
|
|
45453fcf4f | ||
|
|
ef7bab4841 | ||
|
|
ac277f9ca5 | ||
|
|
44dab0287b | ||
|
|
88b75ec0cb | ||
|
|
cbb45ce68c | ||
|
|
b0c4dbf533 | ||
|
|
1891b63a0e | ||
|
|
c420fec8ee | ||
|
|
2ee2a5dfbe | ||
|
|
a5b98f9ea2 | ||
|
|
baedab2960 | ||
|
|
004e18bbb8 | ||
|
|
72e364afe9 | ||
|
|
86ff2dfcb2 | ||
|
|
69ce73012c | ||
|
|
f3bd1a2e11 | ||
|
|
a819283661 | ||
|
|
8255c1e929 | ||
|
|
eb7990f8dc | ||
|
|
982476b2d0 | ||
|
|
3ec653c3b7 | ||
|
|
5dc5b3795e | ||
|
|
f5f77a80a3 | ||
|
|
1d1099492e | ||
|
|
ccc2564929 | ||
|
|
084574ffd4 | ||
|
|
6a6978c491 | ||
|
|
0399375288 | ||
|
|
394346fbc1 | ||
|
|
0feec109bd | ||
|
|
fdb5c9b536 | ||
|
|
f7ad8aed60 | ||
|
|
4621d7e79f | ||
|
|
b64a49cfb0 | ||
|
|
aaddfbaf40 | ||
|
|
994da5e1ee | ||
|
|
811e282e28 | ||
|
|
892fcf8168 | ||
|
|
032ee9243f | ||
|
|
30cdf34d56 | ||
|
|
fd52caa3fd | ||
|
|
9788712808 | ||
|
|
af9d086f05 | ||
|
|
fc7190868e | ||
|
|
d3c1892c0b | ||
|
|
1fb4cdf3ca | ||
|
|
7928183036 | ||
|
|
ffa276df9e | ||
|
|
9f05e66e9d | ||
|
|
8ba90ded85 | ||
|
|
4e5e2f06d3 | ||
|
|
72396254d2 | ||
|
|
f9313006ba | ||
|
|
c894d701ff | ||
|
|
e9d0466018 | ||
|
|
28b4f61f20 | ||
|
|
ab6b3b225d | ||
|
|
ffb4a43e38 | ||
|
|
0653e2dc97 | ||
|
|
912e03d2ce | ||
|
|
a8df765cda | ||
|
|
b865b3ff84 | ||
|
|
69c1e8a5b8 | ||
|
|
c1522f1005 | ||
|
|
8144d24b06 | ||
|
|
4c2e2efbb5 | ||
|
|
ad452c9a7a | ||
|
|
a43da1304d | ||
|
|
c2da59a92f | ||
|
|
a89166d1c4 | ||
|
|
45039593bf | ||
|
|
d56c35cdfb | ||
|
|
73836c6800 | ||
|
|
f9a0440db8 | ||
|
|
6e7a8c1ef6 | ||
|
|
44147cc062 | ||
|
|
352b756e22 | ||
|
|
a5d9eb757f | ||
|
|
a19a73d675 | ||
|
|
4a57f87284 | ||
|
|
b523fe4c58 | ||
|
|
c208450abc | ||
|
|
b73ab2a781 | ||
|
|
ec679cc23c | ||
|
|
1dbbcc4d21 | ||
|
|
1573b61584 | ||
|
|
1ac8cc7674 | ||
|
|
793db4e9b6 | ||
|
|
12863e557a | ||
|
|
d999fc1e06 | ||
|
|
6c288d160c | ||
|
|
57d5dd8b20 | ||
|
|
0a8c0e0c7f | ||
|
|
f72bd4cc5c | ||
|
|
26cce1faac | ||
|
|
3101b4f423 | ||
|
|
e8e30faf3e | ||
|
|
3f6158fc47 | ||
|
|
3465645b70 | ||
|
|
2dd397757d | ||
|
|
5496419bb4 | ||
|
|
f1b950c38e | ||
|
|
4a3e97ad52 | ||
|
|
9fe17996b4 | ||
|
|
c7a2f87148 | ||
|
|
1125dd3fe7 | ||
|
|
adf48aee44 | ||
|
|
534d8a333e | ||
|
|
03fc91c19c | ||
|
|
d2429dada2 | ||
|
|
0fae323647 | ||
|
|
fae58d56be | ||
|
|
fc56d5e7cb | ||
|
|
04c85acae0 | ||
|
|
af2fd9bc09 | ||
|
|
d4ce2a213f | ||
|
|
7d9ba805e4 | ||
|
|
4c315ca3b8 | ||
|
|
bcf812ef7a | ||
|
|
fd603aa76f | ||
|
|
e34c08c803 | ||
|
|
9252ef1531 | ||
|
|
63153ca195 | ||
|
|
a8c2f69251 | ||
|
|
91eaef05e1 | ||
|
|
4822642fd7 | ||
|
|
a712d8f900 | ||
|
|
86e507f93e | ||
|
|
86eba276c5 | ||
|
|
4f18a98a4a | ||
|
|
547eff9f06 | ||
|
|
5bd1225d3f | ||
|
|
309a0aa94c | ||
|
|
f8286a6334 | ||
|
|
916086d60e | ||
|
|
4fa0eb0a87 | ||
|
|
295eb57922 | ||
|
|
2179cf57b6 | ||
|
|
cca0f2d5db | ||
|
|
d3c3ffb88d | ||
|
|
10c7196f9f | ||
|
|
254def6152 | ||
|
|
09bae2565c | ||
|
|
5572918750 | ||
|
|
4e91db09a6 | ||
|
|
bbaee20097 | ||
|
|
7554e2b03e | ||
|
|
3baac1138f | ||
|
|
8815cef66c | ||
|
|
afa836c6df | ||
|
|
bf513508e4 | ||
|
|
a0423f301d | ||
|
|
302fa8f4dd | ||
|
|
4a75a22a6c | ||
|
|
c7d5cf1cc2 | ||
|
|
e031ca3ef4 | ||
|
|
8e0e926a68 | ||
|
|
d4ed634c4e | ||
|
|
18582f1a2d | ||
|
|
3688b128e4 | ||
|
|
4718b75f89 | ||
|
|
aa7f8f79a5 | ||
|
|
c794a6aaf8 | ||
|
|
0786a45995 | ||
|
|
66fde65550 | ||
|
|
caf56aca94 | ||
|
|
06581692e6 | ||
|
|
ca2fbd9fdd | ||
|
|
81ce6c9950 | ||
|
|
31b67f2217 | ||
|
|
0ee76d39b2 | ||
|
|
6a3f4816d5 | ||
|
|
1365754d62 | ||
|
|
ac4c98bead | ||
|
|
74306719ca | ||
|
|
cc028dd37f | ||
|
|
43184fff6d | ||
|
|
d41f94f254 | ||
|
|
fb741a4e54 | ||
|
|
4319dfd0fe | ||
|
|
a06a0e893c | ||
|
|
8517092a88 | ||
|
|
52e79dfaa7 | ||
|
|
7fb19fd032 | ||
|
|
6344657940 | ||
|
|
3f01a99b66 | ||
|
|
69d7350e89 | ||
|
|
9ee502f437 | ||
|
|
b8b876535b | ||
|
|
816edefe25 | ||
|
|
c523ff608e | ||
|
|
663f14c439 | ||
|
|
20c6ef6702 | ||
|
|
91241f045f | ||
|
|
82e5c54cdc | ||
|
|
4b17102e5a | ||
|
|
bc58324a56 | ||
|
|
b43bcf244a | ||
|
|
befe1a0bdc | ||
|
|
1aae5433f3 | ||
|
|
afeb12686b | ||
|
|
75f4b94b26 | ||
|
|
4a867904ad | ||
|
|
249b161243 | ||
|
|
a709a3268a | ||
|
|
50b8a06f63 | ||
|
|
29e6338b41 | ||
|
|
82e9fd7f2e | ||
|
|
8eb41eec53 | ||
|
|
07f96019b1 | ||
|
|
1a0d4cb8cd | ||
|
|
5ae34047ee | ||
|
|
260181ec7c | ||
|
|
21bc826eda | ||
|
|
e1d9125a5c | ||
|
|
8d1b836f59 | ||
|
|
bd50399483 | ||
|
|
d641d25392 | ||
|
|
a8c9f41fce | ||
|
|
2ff9d84584 | ||
|
|
11cd61cd44 | ||
|
|
cf43988fae | ||
|
|
4ef27e3027 | ||
|
|
5ffabb387b | ||
|
|
d78a45b7e5 | ||
|
|
bd5a9c382d | ||
|
|
e238e829ad | ||
|
|
689c20d367 | ||
|
|
43588df2b4 | ||
|
|
d2aaddea33 | ||
|
|
de233f8ae5 | ||
|
|
3179e74dc7 | ||
|
|
6f5e9f5d49 | ||
|
|
8a43fdf200 | ||
|
|
69051e5bbb | ||
|
|
c648e7e0ec | ||
|
|
c91cf376ad | ||
|
|
f4e07597d0 | ||
|
|
64b64d3340 | ||
|
|
57ea87871e | ||
|
|
6154d80bb9 | ||
|
|
ad85745f85 | ||
|
|
41fc77322c | ||
|
|
1b83c26417 | ||
|
|
10ea5f0b93 | ||
|
|
032684de68 | ||
|
|
3efec5ce58 | ||
|
|
4ed32964d9 | ||
|
|
05c1d0cc6e | ||
|
|
3c54924971 | ||
|
|
e57dfc0e3f | ||
|
|
eff8bb16a0 | ||
|
|
87e681b71e | ||
|
|
ac73ef4ea7 | ||
|
|
20945e7443 | ||
|
|
b7b1278111 | ||
|
|
6f9378404f | ||
|
|
79787aca6e |
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ "ubuntu-latest", "ubuntu-22.04-arm", "windows-latest" ]
|
os: [ "ubuntu-24.04", "ubuntu-22.04-arm", "windows-latest" ]
|
||||||
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
database: [ "h2", "postgresql", "mysql", "mariadb" ]
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -29,14 +29,14 @@ jobs:
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
- name: Set up GraalVM
|
- name: Set up GraalVM
|
||||||
uses: graalvm/setup-graalvm@5298d94fb55a4f185c602eeac5de1b553882abe2 # v1.6.4
|
uses: graalvm/setup-graalvm@f70bc8032f90a104502824a54bcb94b6f01715bc # v1.6.8
|
||||||
with:
|
with:
|
||||||
java-version: ${{ env.JAVA_VERSION }}
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
distribution: "graalvm"
|
distribution: "mandrel"
|
||||||
cache: "maven"
|
cache: "maven"
|
||||||
|
|
||||||
- name: Set up NodeJS cache
|
- name: Set up NodeJS cache
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||||
with:
|
with:
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: 'commafeed-client/package-lock.json'
|
cache-dependency-path: 'commafeed-client/package-lock.json'
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
|
|
||||||
# Build & Test
|
# Build & Test
|
||||||
- name: Build with Maven
|
- name: Build with Maven
|
||||||
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipITs=${{ matrix.os == 'windows-latest' && matrix.database != 'h2' }}
|
||||||
|
|
||||||
# Build pages
|
# Build pages
|
||||||
- name: Create pages directory structure
|
- name: Create pages directory structure
|
||||||
@@ -73,21 +73,21 @@ jobs:
|
|||||||
|
|
||||||
# Upload artifacts
|
# Upload artifacts
|
||||||
- name: Upload cross-platform app
|
- name: Upload cross-platform app
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
|
if: matrix.os == 'ubuntu-24.04' # we only need to upload the cross-platform artifact once per database
|
||||||
with:
|
with:
|
||||||
name: commafeed-${{ matrix.database }}-jvm
|
name: commafeed-${{ matrix.database }}-jvm
|
||||||
path: commafeed-server/target/commafeed-*.zip
|
path: commafeed-server/target/commafeed-*.zip
|
||||||
|
|
||||||
- name: Upload native executable
|
- name: Upload native executable
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
path: commafeed-server/target/commafeed-*-runner*
|
path: commafeed-server/target/commafeed-*-runner*
|
||||||
|
|
||||||
- name: Upload pages
|
- name: Upload pages
|
||||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||||
if: matrix.os == 'ubuntu-latest' && matrix.database == 'h2' # we only need to upload the pages once
|
if: matrix.os == 'ubuntu-24.04' && matrix.database == 'h2' # we only need to upload the pages once
|
||||||
with:
|
with:
|
||||||
path: target/pages
|
path: target/pages
|
||||||
|
|
||||||
@@ -110,17 +110,17 @@ jobs:
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Install required packages
|
- name: Install required packages
|
||||||
run: sudo apt-get install -y rename unzip
|
run: sudo apt-get install -y rename unzip
|
||||||
|
|
||||||
# Prepare artifacts
|
# Prepare artifacts
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
pattern: commafeed-${{ matrix.database }}-*
|
pattern: commafeed-${{ matrix.database }}-*
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
if: ${{ env.DOCKERHUB_USERNAME != '' }}
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
|
|
||||||
## build but don't push for PRs and renovate
|
## build but don't push for PRs and renovate
|
||||||
- name: Docker build - native
|
- name: Docker build - native
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.native
|
file: commafeed-server/src/main/docker/Dockerfile.native
|
||||||
@@ -157,7 +157,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64/v8
|
platforms: linux/amd64,linux/arm64/v8
|
||||||
|
|
||||||
- name: Docker build - jvm
|
- name: Docker build - jvm
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
file: commafeed-server/src/main/docker/Dockerfile.jvm
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
|
|
||||||
## build and push tag
|
## build and push tag
|
||||||
- name: Docker build and push tag - native
|
- name: Docker build and push tag - native
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -178,7 +178,7 @@ jobs:
|
|||||||
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
|
||||||
|
|
||||||
- name: Docker build and push tag - jvm
|
- name: Docker build and push tag - jvm
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
|
|
||||||
## build and push master
|
## build and push master
|
||||||
- name: Docker build and push master - native
|
- name: Docker build and push master - native
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_name == 'master' }}
|
if: ${{ github.ref_name == 'master' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
tags: athou/commafeed:master-${{ matrix.database }}
|
tags: athou/commafeed:master-${{ matrix.database }}
|
||||||
|
|
||||||
- name: Docker build and push master - jvm
|
- name: Docker build and push master - jvm
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
if: ${{ github.ref_name == 'master' }}
|
if: ${{ github.ref_name == 'master' }}
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -226,7 +226,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
pattern: commafeed-*
|
pattern: commafeed-*
|
||||||
path: ./artifacts
|
path: ./artifacts
|
||||||
@@ -280,5 +280,5 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||||
id: deployment
|
id: deployment
|
||||||
|
|||||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,3 +1,3 @@
|
|||||||
wrapperVersion=3.3.4
|
wrapperVersion=3.3.4
|
||||||
distributionType=only-script
|
distributionType=only-script
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
|
||||||
|
|||||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -1,23 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [7.4.1]
|
||||||
|
|
||||||
|
- Fix entries being marked as read when scrolling back to an entry that was explicitly marked as unread (#2337)
|
||||||
|
- Add a global failsafe timeout to the new HTTP/2 client to prevent it from hanging indefinitely
|
||||||
|
- Add OCI source label to container images (#2335)
|
||||||
|
|
||||||
|
## [7.4.0]
|
||||||
|
|
||||||
|
- Add support for HTTP/2 when fetching feeds (#2317)
|
||||||
|
- Send more default headers when fetching feeds, to avoid being rejected by some servers (#2315)
|
||||||
|
- Harden the content cleaning of feed entries by only allowing `http`/`https` protocols in `iframe` `src` attributes
|
||||||
|
- Fix the header in mobile mode being wrapped/cut off when too wide, even above the mobile breakpoint (#2080)
|
||||||
|
- Document how to configure Quarkus host validation when running CommaFeed behind a reverse proxy
|
||||||
|
|
||||||
|
## [7.3.2]
|
||||||
|
|
||||||
|
- Prevent users from starring/tagging entries that are not theirs and subsequently reading them (GHSA-prfv-88mm-5gpg)
|
||||||
|
|
||||||
## [7.3.1]
|
## [7.3.1]
|
||||||
|
|
||||||
- Prevent Host header injection attacks on the password recovery endpoint. A new setting
|
- Prevent Host header injection attacks on the password recovery endpoint. A new setting
|
||||||
`commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery
|
`commafeed.password-recovery-public-base-url` has been added to specify the base URL to use in password recovery
|
||||||
|
(GHSA-hp8h-jqfm-v7x5)
|
||||||
emails. This setting is only required when the password recovery feature is enabled, which is not the default.
|
emails. This setting is only required when the password recovery feature is enabled, which is not the default.
|
||||||
|
|
||||||
## [7.3.0]
|
## [7.3.0]
|
||||||
|
|
||||||
- Add support for the Google Reader API for native mobile apps that don't support the Fever API
|
- Add support for the Google Reader API for native mobile apps that don't support the Fever API
|
||||||
- The profile menu now works again on mobile when action buttons are at the bottom of the screen
|
- The profile menu now works again on mobile when action buttons are at the bottom of the screen
|
||||||
- More SSRF protection regarding IPv6
|
- More SSRF protection regarding IPv6 (GHSA-q5qw-345w-55xg)
|
||||||
- Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to
|
- Changed the default value of `blockLocalAddresses` to make CommaFeed more secure out-of-the-box. See the "Access to
|
||||||
local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
|
local address blocked" section of the README if you subscribe to feeds that are only available on your local network.
|
||||||
|
|
||||||
## [7.2.1]
|
## [7.2.1]
|
||||||
|
|
||||||
- correctly handle favicons hosted at the root of the domain for feeds with non-default ports
|
- correctly handle favicons hosted at the root of the domain for feeds with non-default ports
|
||||||
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:" URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API.
|
- prevent XXS injections in third party apps. The CommaFeed React client was already stripping malicious "javascript:"
|
||||||
|
URLs, they are now filtered during feed parsing and no longer stored in the database/served through the REST API
|
||||||
|
(GHSA-44pq-9929-f8mq)
|
||||||
|
|
||||||
## [7.2.0]
|
## [7.2.0]
|
||||||
|
|
||||||
@@ -27,26 +48,36 @@
|
|||||||
- Small performance improvements to the feed refresh engine
|
- Small performance improvements to the feed refresh engine
|
||||||
- Removed the Pocket sharing feature because the Pocket service has been discontinued
|
- Removed the Pocket sharing feature because the Pocket service has been discontinued
|
||||||
- Prevent the image proxy feature to access any URL to avoid SSRF attacks
|
- Prevent the image proxy feature to access any URL to avoid SSRF attacks
|
||||||
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges
|
- Strengthened the SSRF protection by also blocking ULA and CGNAT ranges (GHSA-hgrr-mjfp-gmr6)
|
||||||
- Prevent DDOS attacks by filtering OPML files during imports
|
- Prevent DDOS attacks by filtering OPML files during imports (GHSA-83jp-rww3-57rr)
|
||||||
|
|
||||||
## [7.1.0]
|
## [7.1.0]
|
||||||
|
|
||||||
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not visible otherwise (#2055)
|
- Display the unread count in the header on mobile, since the tree is hidden by default and the unread count is not
|
||||||
|
visible otherwise (#2055)
|
||||||
- The feed refresh engine is now a little bit more performant (#2089)
|
- The feed refresh engine is now a little bit more performant (#2089)
|
||||||
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
|
- On shutdown, give the feed refresh engine some time to finish refreshing feeds before killing it. This is controlled
|
||||||
|
by the new `commafeed.shutdown-timeout` setting, which defaults to 2 seconds
|
||||||
|
|
||||||
## [7.0.0]
|
## [7.0.0]
|
||||||
|
|
||||||
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by default.
|
- Replaced the JEXL filter expression for marking feed entries as read automatically with a user-friendly visual query
|
||||||
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are discovered (#1610)
|
builder. Expressions are now evaluated with Common Expression Language, which is safer than JEXL and sanboxed by
|
||||||
|
default.
|
||||||
|
- Added a per-feed setting for sending push notifications to ntfy, Gotify or Pushover when new feed entries are
|
||||||
|
discovered (#1610)
|
||||||
- Added a per-feed setting for marking entries as read after a number of days (#2041)
|
- Added a per-feed setting for marking entries as read after a number of days (#2041)
|
||||||
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
- The default value of `commafeed.http-client.block-local-addresses` is now false, allowing users to subscribe to feeds
|
||||||
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites redirecting to local ones.
|
only available on their local network. This may be a security risk (SSRF) if your instance is accessible by untrusted
|
||||||
|
users, so you may want to set it to true if you host a public instance of CommaFeed with user registeration enabled.
|
||||||
|
- When `commafeed.http-client.block-local-addresses` is enabled, SSRF is now also mitigated by blocking public websites
|
||||||
|
redirecting to local ones.
|
||||||
|
|
||||||
## [6.2.0]
|
## [6.2.0]
|
||||||
|
|
||||||
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new `commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want to keep deleting starred entries during normal entries cleanup (#1581)
|
- Starred entries are no longer deleted after a certain amount of time, they are now kept indefinitely. The new
|
||||||
|
`commafeed.database.cleanup.keep-starred-entries` setting can be disabled to restore the previous behavior if you want
|
||||||
|
to keep deleting starred entries during normal entries cleanup (#1581)
|
||||||
|
|
||||||
## [6.1.1]
|
## [6.1.1]
|
||||||
|
|
||||||
@@ -54,16 +85,21 @@
|
|||||||
|
|
||||||
## [6.1.0]
|
## [6.1.0]
|
||||||
|
|
||||||
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now redirected to a page where they can set their own password (#2023)
|
- When clicking on the password reset link, a random password is no longer generated automatically. The user is now
|
||||||
|
redirected to a page where they can set their own password (#2023)
|
||||||
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
- Use browser preferred language instead of English when using CommaFeed for the first time (#2018)
|
||||||
- The profile menu is now closed when scrolling the page (#2019)
|
- The profile menu is now closed when scrolling the page (#2019)
|
||||||
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
- The "disable pull to refresh" feature is now disabled by default (#2030)
|
||||||
|
|
||||||
## [6.0.0]
|
## [6.0.0]
|
||||||
|
|
||||||
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup wizard will guide you through the creation of an admin account
|
- When booting CommaFeed for the first time, the default "admin" account is no longer created automatically. A setup
|
||||||
- Default password complexity requirements have been lowered for local network deployments, where strict password rules are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by `commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
wizard will guide you through the creation of an admin account
|
||||||
- Email addresses are no longer required when creating users and when they update their profile. The `commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
- Default password complexity requirements have been lowered for local network deployments, where strict password rules
|
||||||
|
are often unnecessary. The `commafeed.users.strict-password-policy` setting has been replaced by
|
||||||
|
`commafeed.users.minimum-password-length` with a default value of `4` (#1916)
|
||||||
|
- Email addresses are no longer required when creating users and when they update their profile. The
|
||||||
|
`commafeed.users.email-address-required` setting has been added to restore the previous behavior (#1914)
|
||||||
- Java 25+ is now required to build and run CommaFeed
|
- Java 25+ is now required to build and run CommaFeed
|
||||||
|
|
||||||
## [5.12.1]
|
## [5.12.1]
|
||||||
@@ -93,15 +129,18 @@
|
|||||||
|
|
||||||
## [5.10.0]
|
## [5.10.0]
|
||||||
|
|
||||||
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is open (#1762)
|
- Add an indicator next to each feed's unread count in the tree to show when new entries are discovered while the app is
|
||||||
|
open (#1762)
|
||||||
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
- Feeds with uppercase HTTP:// or HTTPS:// URLs are now correctly handled again
|
||||||
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
- The aarch64 native executable now also works on the Raspberry Pi 5 (#1795)
|
||||||
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are displayed (#1087)
|
- Improve general performance of the UI by reducing the number of re-renders, especially when a lot of entries are
|
||||||
|
displayed (#1087)
|
||||||
|
|
||||||
## [5.9.0]
|
## [5.9.0]
|
||||||
|
|
||||||
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
- A lot of CSS classes have been added to the elements of the application to ease custom CSS rules (#1757)
|
||||||
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the new CSS classes
|
- Added a link in the README to the [documentation](https://athou.github.io/commafeed/documentation/custom-css/) of the
|
||||||
|
new CSS classes
|
||||||
- Static resources are now cached for much longer (#1782)
|
- Static resources are now cached for much longer (#1782)
|
||||||
|
|
||||||
## [5.8.0]
|
## [5.8.0]
|
||||||
@@ -110,7 +149,8 @@
|
|||||||
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
- A font size slider is now available to change the size of the text of feed entries (#1462)
|
||||||
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
- The "mark all as read" confirmation setting now also applies to the "shift+a" keyboard shortcut (#1744)
|
||||||
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
|
- CommaFeed wil try to match the language of the browser before defaulting to english (#1767)
|
||||||
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to match what other feed readers do
|
- The default value for the number of entries to keep above the selected entry when scrolling is now 1 instead of 0 to
|
||||||
|
match what other feed readers do
|
||||||
|
|
||||||
## [5.7.0]
|
## [5.7.0]
|
||||||
|
|
||||||
@@ -129,14 +169,22 @@
|
|||||||
|
|
||||||
## [5.6.0]
|
## [5.6.0]
|
||||||
|
|
||||||
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is now true. This means feeds no longer refresh exactly every 5 minutes (the default value of `commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new `commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active feeds refresh more often (#1677)
|
- To better respect the bandwidth of feed owners, the default value of `commafeed.feed-refresh.interval-empirical` is
|
||||||
|
now true. This means feeds no longer refresh exactly every 5 minutes (the default value of
|
||||||
|
`commafeed.feed-refresh.interval`) but between 5 minutes and 4 hours (the default value of the new
|
||||||
|
`commafeed.feed-refresh.max-interval` setting). The interval is calculated based on feed activity, so highly active
|
||||||
|
feeds refresh more often (#1677)
|
||||||
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
- Many previously hardcoded values used in feed refresh interval calculation are now exposed as settings (#1677)
|
||||||
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses` setting if you subscribe to feeds only available on your local network and you trust all your users
|
- Access to local addresses is now blocked to mitigate server-side request forgery (SSRF) attacks, which could
|
||||||
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does not contain a "Retry-After" header
|
potentially expose internal resources. You might want to disable the new `commafeed.http-client.block-local-addresses`
|
||||||
|
setting if you subscribe to feeds only available on your local network and you trust all your users
|
||||||
|
- If a feed responds with a "429 - Too many requests" response, a backoff mechanism is triggered when the response does
|
||||||
|
not contain a "Retry-After" header
|
||||||
|
|
||||||
## [5.5.0]
|
## [5.5.0]
|
||||||
|
|
||||||
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this header
|
- CommaFeed now honors the Retry-After response header and will not try to refresh a feed sooner than the value of this
|
||||||
|
header
|
||||||
- Audio enclosures (e.g. podcasts) now fill available entry width
|
- Audio enclosures (e.g. podcasts) now fill available entry width
|
||||||
- Fix an issue with some labels not correctly internationalized
|
- Fix an issue with some labels not correctly internationalized
|
||||||
|
|
||||||
@@ -153,8 +201,10 @@
|
|||||||
## [5.3.5]
|
## [5.3.5]
|
||||||
|
|
||||||
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
- Fixed an issue with the aspect ratio of images of some feeds (#1595)
|
||||||
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age property (#1615)
|
- CommaFeed now honors the Cache-Control response header and will not try to refresh a feed sooner than its max-age
|
||||||
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may need to update it to the most recent patch version to support `-proc:full` (#1618)
|
property (#1615)
|
||||||
|
- Added support for compilation with JDK 23+. If you're building CommaFeed from sources with a JDK 17 or 21, you may
|
||||||
|
need to update it to the most recent patch version to support `-proc:full` (#1618)
|
||||||
|
|
||||||
## [5.3.4]
|
## [5.3.4]
|
||||||
|
|
||||||
@@ -181,19 +231,22 @@
|
|||||||
|
|
||||||
- Added an option to keep a number of entries above the selected entry when scrolling
|
- Added an option to keep a number of entries above the selected entry when scrolling
|
||||||
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
- Added a cache to the HTTP client to reduce the number of requests made to feeds when subscribing (#1431)
|
||||||
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up (every hour)
|
- Feeds are no longer refreshed between the moment its last user unsubscribes and the moment the feed is cleaned up
|
||||||
|
(every hour)
|
||||||
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
- Fixed an issue that could cause entries to not correctly load when using keyboard navigation (#1557)
|
||||||
|
|
||||||
## [5.1.1]
|
## [5.1.1]
|
||||||
|
|
||||||
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
- Fixed database migration issue when upgrading from 5.0.0 to 5.1.0 on MariaDB (#1544)
|
||||||
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is selected (#1543)
|
- When feeds without unread entries are hidden from the tree, the feed is displayed in the tree until another one is
|
||||||
|
selected (#1543)
|
||||||
|
|
||||||
## [5.1.0]
|
## [5.1.0]
|
||||||
|
|
||||||
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
- Added a setting for showing/hiding unread count in the browser's tab title/favicon (#1518)
|
||||||
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
- Fixed an issue that could prevent the app from starting on some systems (#1532)
|
||||||
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to date
|
- Added a cache busting filter for the webapp index.html and openapi documentation to make sure they are always up to
|
||||||
|
date
|
||||||
- Reduced database cleanup log verbosity
|
- Reduced database cleanup log verbosity
|
||||||
|
|
||||||
## [5.0.2]
|
## [5.0.2]
|
||||||
@@ -208,9 +261,9 @@
|
|||||||
## [5.0.0]
|
## [5.0.0]
|
||||||
|
|
||||||
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
CommaFeed is now powered by Quarkus instead of Dropwizard. Read the rationale behind this change in
|
||||||
the [announcement](https://github.com/Athou/commafeed/discussions/1517).
|
the [announcement](https://github.com/Athou/commafeed/discussions/1517). The gist of it is that CommaFeed can now be
|
||||||
The gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
|
compiled to a native binary, resulting in blazing fast startup times (around 0.3s) and very low memory footprint (<
|
||||||
0.3s) and very low memory footprint (< 50M).
|
50M).
|
||||||
|
|
||||||
- CommaFeed now has a different package for each supported database.
|
- CommaFeed now has a different package for each supported database.
|
||||||
- If you are deploying CommaFeed with a precompiled package, please
|
- If you are deploying CommaFeed with a precompiled package, please
|
||||||
@@ -266,8 +319,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
- add a button in the entry headers to open links in a new tab (#1333)
|
- add a button in the entry headers to open links in a new tab (#1333)
|
||||||
- add two options in the settings to toggle those buttons
|
- add two options in the settings to toggle those buttons
|
||||||
- accept .opml file extension when importing and export with the .opml extension
|
- accept .opml file extension when importing and export with the .opml extension
|
||||||
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read (
|
- the "mark as read" option is no longer shown in the context menu for entries that are too old to be marked as read
|
||||||
older than `keepStatusDays`) (#1303)
|
(older than `keepStatusDays`) (#1303)
|
||||||
|
|
||||||
## [4.3.3]
|
## [4.3.3]
|
||||||
|
|
||||||
@@ -311,9 +364,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
- it is now possible to open the sidebar on mobile by swiping to the right (#1098)
|
||||||
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
- swiping to mark entries as read/unread changed from swiping right to left because swiping right now opens the sidebar
|
||||||
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
- the full hierarchy of categories are now displayed in the category dropdown (#1045)
|
||||||
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup.
|
- added a setting `maxEntriesAgeDays` to delete old entries based on their age during database cleanup. The setting is
|
||||||
The setting is disabled by default for existing installations, except for the docker image where it is enabled and set
|
disabled by default for existing installations, except for the docker image where it is enabled and set to 365 days
|
||||||
to 365 days
|
|
||||||
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
- if user registrations are disabled on your instance which is the default behavior, users are redirected on the login
|
||||||
page instead of the welcome page when not logged in (#1185)
|
page instead of the welcome page when not logged in (#1185)
|
||||||
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
- the sidebar resizer is no longer shown in the middle of the screen on mobile
|
||||||
@@ -339,8 +391,8 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
request, reducing CPU usage
|
request, reducing CPU usage
|
||||||
- updated UI library Mantine to 7.0, improving performance
|
- updated UI library Mantine to 7.0, improving performance
|
||||||
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
- the h2 embedded database is now compacted on shutdown to reclaim unused space
|
||||||
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is
|
- the admin connector on port 8084 is now disabled in config.yml.example. Disabling it in your config.yml is recommended
|
||||||
recommended (see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
(see https://github.com/Athou/commafeed/commit/929df60f09cce56020b0962ab111cd8349b271b0)
|
||||||
- migrated documentation from swagger 2 to openapi 3
|
- migrated documentation from swagger 2 to openapi 3
|
||||||
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
- added a GET method to the fever api to indicate that the endpoint is working correctly when accessed from a browser
|
||||||
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
- the websocket connection can now be disabled, the websocket ping interval and the tree reload interval can now be
|
||||||
@@ -582,7 +634,6 @@ The gist of it is that CommaFeed can now be compiled to a native binary, resulti
|
|||||||
## [2.0.0]
|
## [2.0.0]
|
||||||
|
|
||||||
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
- The backend has been completely rewritten using Dropwizard instead of TomEE, resulting in a lot less memory
|
||||||
consumption and better overall performances.
|
consumption and better overall performances. See the README on how to build CommaFeed from now on.
|
||||||
See the README on how to build CommaFeed from now on.
|
|
||||||
- CommaFeed should no longer fetch the same feed multiple times in a row
|
- CommaFeed should no longer fetch the same feed multiple times in a row
|
||||||
- Users can use their username or email to log in
|
- Users can use their username or email to log in
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -167,6 +167,20 @@ CommaFeed blocks access to local resources by default to prevent [SSRF](https://
|
|||||||
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
If you want to subscribe to feeds that are only available on your local network, you can disable this security measure by setting the `commafeed.http-client.block-local-addresses` variable to `false`.
|
||||||
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
Do this only if you trust all users of your CommaFeed instance not to access private resources.
|
||||||
|
|
||||||
|
### Listen on a single network interface
|
||||||
|
|
||||||
|
By default, CommaFeed listens on all interfaces. You can restrict it by setting `quarkus.http.host`.
|
||||||
|
|
||||||
|
Note that if you set it to a local name like `127.0.0.1` host validation is enabled automatically. This prevents
|
||||||
|
access if you're using a reverse proxy like Nginx. To fix, add your actual hostname to `allowed-hosts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
quarkus.http.host=127.0.0.1
|
||||||
|
quarkus.http.proxy.proxy-address-forwarding=true
|
||||||
|
quarkus.http.proxy.allow-forwarded=true
|
||||||
|
quarkus.http.host-validation.allowed-hosts=commafeed.example.com
|
||||||
|
```
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Files for internationalization are
|
Files for internationalization are
|
||||||
|
|||||||
1
commafeed-client/.npmrc
Normal file
1
commafeed-client/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
loglevel=warn
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"lineEnding": "lf",
|
"lineEnding": "lf",
|
||||||
"lineWidth": 140
|
"lineWidth": 120
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
2015
commafeed-client/package-lock.json
generated
2015
commafeed-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,35 +17,35 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "11.14.0",
|
"@emotion/react": "11.14.0",
|
||||||
"@fontsource/open-sans": "5.3.0",
|
"@fontsource/open-sans": "5.3.0",
|
||||||
"@lingui/core": "6.6.0",
|
"@lingui/core": "6.11.0",
|
||||||
"@lingui/react": "6.6.0",
|
"@lingui/react": "6.11.0",
|
||||||
"@mantine/core": "9.5.1",
|
"@mantine/core": "9.7.4",
|
||||||
"@mantine/dates": "9.5.1",
|
"@mantine/dates": "9.7.4",
|
||||||
"@mantine/form": "9.5.1",
|
"@mantine/form": "9.7.4",
|
||||||
"@mantine/hooks": "9.5.1",
|
"@mantine/hooks": "9.7.4",
|
||||||
"@mantine/modals": "9.5.1",
|
"@mantine/modals": "9.7.4",
|
||||||
"@mantine/notifications": "9.5.1",
|
"@mantine/notifications": "9.7.4",
|
||||||
"@mantine/spotlight": "9.5.1",
|
"@mantine/spotlight": "9.7.4",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@react-querybuilder/mantine": "8.22.5",
|
"@react-querybuilder/mantine": "8.26.0",
|
||||||
"@reduxjs/toolkit": "2.12.0",
|
"@reduxjs/toolkit": "2.13.0",
|
||||||
"@rolldown/plugin-babel": "0.2.3",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"axios": "1.19.0",
|
"axios": "1.21.0",
|
||||||
"dayjs": "1.11.21",
|
"dayjs": "1.11.23",
|
||||||
"escape-string-regexp": "5.0.0",
|
"escape-string-regexp": "5.0.0",
|
||||||
"interweave": "13.1.1",
|
"interweave": "13.1.1",
|
||||||
"monaco-editor": "0.56.0",
|
"monaco-editor": "0.57.0",
|
||||||
"mousetrap": "1.6.5",
|
"mousetrap": "1.6.5",
|
||||||
"react": "19.2.8",
|
"react": "19.3.1",
|
||||||
"react-async-hook": "4.0.0",
|
"react-async-hook": "4.0.0",
|
||||||
"react-contexify": "6.0.0",
|
"react-contexify": "6.0.0",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.3.1",
|
||||||
"react-draggable": "4.7.1",
|
"react-draggable": "4.7.2",
|
||||||
"react-icons": "5.7.0",
|
"react-icons": "5.7.0",
|
||||||
"react-infinite-scroller": "1.2.6",
|
"react-infinite-scroller": "1.2.6",
|
||||||
"react-querybuilder": "8.22.5",
|
"react-querybuilder": "8.26.0",
|
||||||
"react-redux": "9.3.0",
|
"react-redux": "9.3.1",
|
||||||
"react-router-dom": "7.18.2",
|
"react-router-dom": "7.18.5",
|
||||||
"react-swipeable": "7.0.2",
|
"react-swipeable": "7.0.2",
|
||||||
"style-to-object": "2.0.2",
|
"style-to-object": "2.0.2",
|
||||||
"throttle-debounce": "5.0.2",
|
"throttle-debounce": "5.0.2",
|
||||||
@@ -54,32 +54,30 @@
|
|||||||
"websocket-heartbeat-js": "1.1.3"
|
"websocket-heartbeat-js": "1.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/types": "^8.0.4",
|
"@biomejs/biome": "2.6.4",
|
||||||
"@biomejs/biome": "2.5.8",
|
"@lingui/cli": "6.11.0",
|
||||||
"@lingui/babel-plugin-lingui-macro": "6.6.0",
|
"@lingui/vite-plugin": "6.11.0",
|
||||||
"@lingui/cli": "6.6.0",
|
|
||||||
"@lingui/vite-plugin": "6.6.0",
|
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.2",
|
"@testing-library/react": "16.3.3",
|
||||||
"@testing-library/user-event": "14.6.4",
|
"@testing-library/user-event": "14.6.8",
|
||||||
"@types/mousetrap": "1.6.15",
|
"@types/mousetrap": "1.6.15",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.3.0",
|
||||||
"@types/react-infinite-scroller": "1.2.5",
|
"@types/react-infinite-scroller": "1.2.5",
|
||||||
"@types/throttle-debounce": "5.0.2",
|
"@types/throttle-debounce": "5.0.2",
|
||||||
"@types/tinycon": "0.6.7",
|
"@types/tinycon": "0.6.7",
|
||||||
"@vitejs/plugin-react": "6.0.5",
|
"@vitejs/plugin-react": "6.1.2",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"jsdom": "30.0.1",
|
"jsdom": "30.1.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vite": "8.2.1",
|
"vite": "8.3.5",
|
||||||
"vite-plugin-checker": "0.14.5",
|
"vite-plugin-checker": "0.14.5",
|
||||||
"vitest": "4.1.10",
|
"vitest": "5.0.5",
|
||||||
"yaml": "2.9.0"
|
"yaml": "2.9.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react-infinite-scroller": {
|
"react-infinite-scroller": {
|
||||||
"react": "19.2.8"
|
"react": "19.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>7.3.1</version>
|
<version>7.4.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<name>CommaFeed Client</name>
|
<name>CommaFeed Client</name>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<!-- renovate: datasource=node-version depName=node -->
|
<!-- renovate: datasource=node-version depName=node -->
|
||||||
<node.version>v24.19.0</node.version>
|
<node.version>v26.13.0</node.version>
|
||||||
<!-- renovate: datasource=npm depName=npm -->
|
<!-- renovate: datasource=npm depName=npm -->
|
||||||
<npm.version>12.0.2</npm.version>
|
<npm.version>12.2.1</npm.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
<version>3.6.3</version>
|
<version>3.6.4</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>kill-biome</id>
|
<id>kill-biome</id>
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ function Providers(
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
const primaryColor =
|
||||||
|
useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
@@ -87,7 +88,10 @@ function AppRoutes() {
|
|||||||
<Route path="register" element={<RegistrationPage />} />
|
<Route path="register" element={<RegistrationPage />} />
|
||||||
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
<Route path="passwordRecovery" element={<PasswordRecoveryPage />} />
|
||||||
<Route path="passwordReset" element={<PasswordResetPage />} />
|
<Route path="passwordReset" element={<PasswordResetPage />} />
|
||||||
<Route path="app" element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}>
|
<Route
|
||||||
|
path="app"
|
||||||
|
element={<Layout header={<Header />} sidebar={<Tree />} sidebarVisible={sidebarVisible} />}
|
||||||
|
>
|
||||||
<Route path="category">
|
<Route path="category">
|
||||||
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
<Route path=":id" element={<FeedEntriesPage sourceType="category" />} />
|
||||||
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
<Route path=":id/details" element={<CategoryDetailsPage />} />
|
||||||
|
|||||||
36
commafeed-client/src/app/client.test.ts
Normal file
36
commafeed-client/src/app/client.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { i18n } from "@lingui/core"
|
||||||
|
import type { AxiosError } from "axios"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { errorToStrings } from "./client"
|
||||||
|
|
||||||
|
const axiosError = (status: number, data: unknown) =>
|
||||||
|
({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status, data },
|
||||||
|
}) as AxiosError
|
||||||
|
|
||||||
|
describe("errorToStrings", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
it("translates known application error types", () => {
|
||||||
|
vi.spyOn(i18n, "_").mockReturnValue("Translated authentication error")
|
||||||
|
const error = axiosError(401, {
|
||||||
|
type: "WRONG_USERNAME_OR_PASSWORD",
|
||||||
|
message: "wrong username or password",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["Translated authentication error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unexpected errors", () => {
|
||||||
|
const error = axiosError(500, { message: "unexpected error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unexpected error"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves backend messages for unknown application error types", () => {
|
||||||
|
const error = axiosError(400, { type: "UNKNOWN_ERROR", message: "unknown error" })
|
||||||
|
|
||||||
|
expect(errorToStrings(error)).toEqual(["unknown error"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { i18n, type MessageDescriptor } from "@lingui/core"
|
||||||
|
import { msg } from "@lingui/core/macro"
|
||||||
import axios, { type AxiosError } from "axios"
|
import axios, { type AxiosError } from "axios"
|
||||||
import type {
|
import type {
|
||||||
AddCategoryRequest,
|
AddCategoryRequest,
|
||||||
@@ -6,6 +8,8 @@ import type {
|
|||||||
Category,
|
Category,
|
||||||
CategoryModificationRequest,
|
CategoryModificationRequest,
|
||||||
CollapseRequest,
|
CollapseRequest,
|
||||||
|
CommaFeedApplicationError,
|
||||||
|
CommaFeedExceptionType,
|
||||||
Entries,
|
Entries,
|
||||||
FeedInfo,
|
FeedInfo,
|
||||||
FeedInfoRequest,
|
FeedInfoRequest,
|
||||||
@@ -31,6 +35,10 @@ import type {
|
|||||||
UserModel,
|
UserModel,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
const applicationErrorMessages = {
|
||||||
|
WRONG_USERNAME_OR_PASSWORD: msg`Wrong username or password`,
|
||||||
|
} satisfies Record<CommaFeedExceptionType, MessageDescriptor>
|
||||||
|
|
||||||
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
const axiosInstance = axios.create({ baseURL: "./rest", withCredentials: true })
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
response => response,
|
response => response,
|
||||||
@@ -53,7 +61,8 @@ export const client = {
|
|||||||
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
getRoot: async () => await axiosInstance.get<Category>("category/get"),
|
||||||
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
modify: async (req: CategoryModificationRequest) => await axiosInstance.post("category/modify", req),
|
||||||
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
collapse: async (req: CollapseRequest) => await axiosInstance.post("category/collapse", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("category/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("category/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("category/mark", req),
|
||||||
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
add: async (req: AddCategoryRequest) => await axiosInstance.post("category/add", req),
|
||||||
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
delete: async (req: IDRequest) => await axiosInstance.post("category/delete", req),
|
||||||
@@ -68,7 +77,8 @@ export const client = {
|
|||||||
feed: {
|
feed: {
|
||||||
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
get: async (id: string) => await axiosInstance.get<Subscription>(`feed/get/${id}`),
|
||||||
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
modify: async (req: FeedModificationRequest) => await axiosInstance.post("feed/modify", req),
|
||||||
getEntries: async (req: GetEntriesPaginatedRequest) => await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
getEntries: async (req: GetEntriesPaginatedRequest) =>
|
||||||
|
await axiosInstance.get<Entries>("feed/entries", { params: req }),
|
||||||
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
markEntries: async (req: MarkRequest) => await axiosInstance.post("feed/mark", req),
|
||||||
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
fetchFeed: async (req: FeedInfoRequest) => await axiosInstance.post<FeedInfo>("feed/fetch", req),
|
||||||
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
refreshAll: async () => await axiosInstance.get("feed/refreshAll"),
|
||||||
@@ -99,7 +109,8 @@ export const client = {
|
|||||||
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
|
||||||
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
initialSetup: async (req: InitialSetupRequest) => await axiosInstance.post("user/initialSetup", req),
|
||||||
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
|
||||||
passwordResetCallback: async (req: PasswordResetConfirmationRequest) => await axiosInstance.post("user/passwordResetCallback", req),
|
passwordResetCallback: async (req: PasswordResetConfirmationRequest) =>
|
||||||
|
await axiosInstance.post("user/passwordResetCallback", req),
|
||||||
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
|
||||||
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
|
||||||
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
sendTestPushNotification: async (settings: PushNotificationSettings) =>
|
||||||
@@ -128,18 +139,33 @@ export const errorToStrings = (err: unknown) => {
|
|||||||
let strings: string[] = []
|
let strings: string[] = []
|
||||||
|
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
if (typeof err.response.data === "string") strings.push(err.response.data)
|
if (isCommaFeedApplicationError(err)) {
|
||||||
if (isMessageError(err)) strings.push(err.response.data.message)
|
strings.push(i18n._(applicationErrorMessages[err.response.data.type]))
|
||||||
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
} else {
|
||||||
|
if (typeof err.response.data === "string") strings.push(err.response.data)
|
||||||
|
if (isMessageError(err)) strings.push(err.response.data.message)
|
||||||
|
if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings
|
return strings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCommaFeedApplicationError(err: AxiosError): err is AxiosError<CommaFeedApplicationError> {
|
||||||
|
const data = err.response?.data
|
||||||
|
if (!data || typeof data !== "object" || !("type" in data)) return false
|
||||||
|
const type = data.type
|
||||||
|
return typeof type === "string" && Object.hasOwn(applicationErrorMessages, type)
|
||||||
|
}
|
||||||
|
|
||||||
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
function isMessageError(err: AxiosError): err is AxiosError<{ message: string }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "message" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> {
|
||||||
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
return (
|
||||||
|
!!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,30 @@ describe("entries", () => {
|
|||||||
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
expect(client.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("flags an entry marked as unread by the user until entries are reloaded", async () => {
|
||||||
|
vi.mocked(client.feed.getEntries).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
entries: [{ id: "3", read: true, markable: true } as Entry],
|
||||||
|
hasMore: false,
|
||||||
|
name: "my-feed",
|
||||||
|
errorCount: 0,
|
||||||
|
feedLink: "https://mysite.com/feed",
|
||||||
|
timestamp: 123,
|
||||||
|
ignoredReadStatus: false,
|
||||||
|
},
|
||||||
|
} as AxiosResponse<Entries>)
|
||||||
|
|
||||||
|
const store = configureStore({ reducer: reducers })
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
|
||||||
|
|
||||||
|
store.dispatch(markEntry({ entry: store.getState().entries.entries[0], read: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBe(true)
|
||||||
|
|
||||||
|
// reloading the entry list resets the flag
|
||||||
|
await store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: false }))
|
||||||
|
expect(store.getState().entries.entries[0].keepUnread).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it("marks all entries as read", () => {
|
it("marks all entries as read", () => {
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: reducers,
|
reducer: reducers,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { loadEntries, loadMoreEntries, markAllEntries, markEntry, markMultipleEntries, starEntry, tagEntry } from "@/app/entries/thunks"
|
import {
|
||||||
|
loadEntries,
|
||||||
|
loadMoreEntries,
|
||||||
|
markAllEntries,
|
||||||
|
markEntry,
|
||||||
|
markMultipleEntries,
|
||||||
|
starEntry,
|
||||||
|
tagEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
import type { Entry } from "@/app/types"
|
import type { Entry } from "@/app/types"
|
||||||
|
|
||||||
export type EntrySourceType = "category" | "feed" | "tag"
|
export type EntrySourceType = "category" | "feed" | "tag"
|
||||||
@@ -10,7 +18,11 @@ export interface EntrySource {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpendableEntry = Entry & { expanded?: boolean }
|
export type ExpendableEntry = Entry & {
|
||||||
|
expanded?: boolean
|
||||||
|
/** set when the user explicitly marks the entry as unread, prevents it from being marked as read again by scrolling in expanded mode until the entry list is reloaded */
|
||||||
|
keepUnread?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface EntriesState {
|
interface EntriesState {
|
||||||
/** selected source */
|
/** selected source */
|
||||||
@@ -71,20 +83,26 @@ export const entriesSlice = createSlice({
|
|||||||
builder.addCase(markEntry.pending, (state, action) => {
|
builder.addCase(markEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
builder.addCase(markMultipleEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
|
||||||
e.read = action.meta.arg.read
|
e.read = action.meta.arg.read
|
||||||
|
if (!action.meta.arg.read) e.keepUnread = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(markAllEntries.pending, (state, action) => {
|
builder.addCase(markAllEntries.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
|
for (const e of state.entries.filter(e =>
|
||||||
|
action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true
|
||||||
|
)) {
|
||||||
e.read = true
|
e.read = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(starEntry.pending, (state, action) => {
|
builder.addCase(starEntry.pending, (state, action) => {
|
||||||
for (const e of state.entries.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId)) {
|
for (const e of state.entries.filter(
|
||||||
|
e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId
|
||||||
|
)) {
|
||||||
e.starred = action.meta.arg.starred
|
e.starred = action.meta.arg.starred
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_,
|
|||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const { source } = state.entries
|
const { source } = state.entries
|
||||||
const offset =
|
const offset =
|
||||||
state.user.settings?.readingMode === "all" || (source.type === "category" && source.id === "starred")
|
state.user.settings?.readingMode === "all" ||
|
||||||
|
state.entries.search ||
|
||||||
|
(source.type === "category" && source.id === "starred")
|
||||||
? state.entries.entries.length
|
? state.entries.entries.length
|
||||||
: state.entries.entries.filter(e => !e.read).length
|
: state.entries.entries.filter(e => !e.read).length
|
||||||
const endpoint = getEndpoint(state.entries.source.type)
|
const endpoint = getEndpoint(state.entries.source.type)
|
||||||
@@ -137,6 +139,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
|||||||
const state = thunkApi.getState()
|
const state = thunkApi.getState()
|
||||||
const source = state.entries.source
|
const source = state.entries.source
|
||||||
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
const entriesTimestamp = state.entries.timestamp ?? Date.now()
|
||||||
|
const search = state.entries.search
|
||||||
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
const markAllAsReadConfirmation = state.user.settings?.markAllAsReadConfirmation
|
||||||
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
const markAllAsReadNavigateToNextUnread = state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
|
||||||
@@ -151,6 +154,7 @@ export const markAllAsReadWithConfirmationIfRequired = createAppAsyncThunk(
|
|||||||
read: true,
|
read: true,
|
||||||
olderThan: Date.now(),
|
olderThan: Date.now(),
|
||||||
insertedBefore: entriesTimestamp,
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -166,7 +170,6 @@ export const starEntry = createAppAsyncThunk(
|
|||||||
(arg: { entry: Entry; starred: boolean }) => {
|
(arg: { entry: Entry; starred: boolean }) => {
|
||||||
client.entry.star({
|
client.entry.star({
|
||||||
id: arg.entry.id,
|
id: arg.entry.id,
|
||||||
feedId: +arg.entry.feedId,
|
|
||||||
starred: arg.starred,
|
starred: arg.starred,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -219,7 +222,9 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
|
|
||||||
const entryIndex = state.entries.entries.indexOf(entry)
|
const entryIndex = state.entries.entries.indexOf(entry)
|
||||||
const entriesToKeepOnTopWhenScrolling =
|
const entriesToKeepOnTopWhenScrolling =
|
||||||
viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
viewMode === "expanded"
|
||||||
|
? 0
|
||||||
|
: Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
|
||||||
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
|
||||||
|
|
||||||
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
const entryElement = document.getElementById(Constants.dom.entryId(entry))
|
||||||
@@ -227,7 +232,8 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
if (entryElement && entryElementToScrollTo) {
|
if (entryElement && entryElementToScrollTo) {
|
||||||
const scrollMode = state.user.settings?.scrollMode
|
const scrollMode = state.user.settings?.scrollMode
|
||||||
const entryEntirelyVisible =
|
const entryEntirelyVisible =
|
||||||
Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
|
Constants.layout.isTopVisible(entryElementToScrollTo) &&
|
||||||
|
Constants.layout.isBottomVisible(entryElement)
|
||||||
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
|
||||||
const scrollSpeed = state.user.settings?.scrollSpeed
|
const scrollSpeed = state.user.settings?.scrollSpeed
|
||||||
const margin = viewMode === "detailed" ? 8 : 3
|
const margin = viewMode === "detailed" ? 8 : 3
|
||||||
@@ -241,7 +247,12 @@ export const selectEntry = createAppAsyncThunk(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
|
const scrollToEntry = (
|
||||||
|
entryElement: HTMLElement,
|
||||||
|
margin: number,
|
||||||
|
scrollSpeed: number | undefined,
|
||||||
|
onScrollEnded: () => void
|
||||||
|
) => {
|
||||||
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
const header = document.getElementsByTagName("header").item(0)?.getBoundingClientRect()
|
||||||
const offset = (header?.bottom ?? 0) + margin
|
const offset = (header?.bottom ?? 0) + margin
|
||||||
scrollToWithCallback({
|
scrollToWithCallback({
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
|||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { redirectTo } from "@/app/redirect/slice"
|
import { redirectTo } from "@/app/redirect/slice"
|
||||||
|
|
||||||
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
|
export const redirectToLogin = createAppAsyncThunk("redirect/login", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/login"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
|
export const redirectToRegistration = createAppAsyncThunk("redirect/register", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/register"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) => thunkApi.dispatch(redirectTo("/setup")))
|
export const redirectToInitialSetup = createAppAsyncThunk("redirect/initialSetup", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/setup"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", () => {
|
||||||
window.location.href = "api-documentation/"
|
window.location.href = "api-documentation/"
|
||||||
@@ -38,15 +44,21 @@ export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details"
|
|||||||
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) => thunkApi.dispatch(redirectTo(`/app/tag/${id}`)))
|
export const redirectToTag = createAppAsyncThunk("redirect/tag", (id: string, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}`))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
export const redirectToTagDetails = createAppAsyncThunk("redirect/tag/details", (id: string, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
thunkApi.dispatch(redirectTo(`/app/tag/${id}/details`))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
|
export const redirectToAdd = createAppAsyncThunk("redirect/add", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/add"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
|
export const redirectToSettings = createAppAsyncThunk("redirect/settings", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/settings"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
export const redirectToAdminUsers = createAppAsyncThunk("redirect/admin/users", (_, thunkApi) =>
|
||||||
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
thunkApi.dispatch(redirectTo("/app/admin/users"))
|
||||||
@@ -56,6 +68,10 @@ export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (
|
|||||||
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
thunkApi.dispatch(redirectTo("/app/admin/metrics"))
|
||||||
)
|
)
|
||||||
|
|
||||||
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/donate")))
|
export const redirectToDonate = createAppAsyncThunk("redirect/donate", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/donate"))
|
||||||
|
)
|
||||||
|
|
||||||
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/about")))
|
export const redirectToAbout = createAppAsyncThunk("redirect/about", (_, thunkApi) =>
|
||||||
|
thunkApi.dispatch(redirectTo("/app/about"))
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { createAppAsyncThunk } from "@/app/async-thunk"
|
import { createAppAsyncThunk } from "@/app/async-thunk"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
|
|
||||||
export const reloadServerInfos = createAppAsyncThunk("server/infos", async () => await client.server.getServerInfos().then(r => r.data))
|
export const reloadServerInfos = createAppAsyncThunk(
|
||||||
|
"server/infos",
|
||||||
|
async () => await client.server.getServerInfos().then(r => r.data)
|
||||||
|
)
|
||||||
|
|||||||
@@ -41,4 +41,5 @@ export type AppDispatch = typeof store.dispatch
|
|||||||
|
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)
|
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector =>
|
||||||
|
useSelector(selector, shallowEqual)
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { incrementUnreadCount } from "@/app/tree/slice"
|
|||||||
import type { CollapseRequest, Subscription } from "@/app/types"
|
import type { CollapseRequest, Subscription } from "@/app/types"
|
||||||
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
import { flattenCategoryTree, visitCategoryTree } from "@/app/utils"
|
||||||
|
|
||||||
export const reloadTree = createAppAsyncThunk("tree/reload", async () => await client.category.getRoot().then(r => r.data))
|
export const reloadTree = createAppAsyncThunk(
|
||||||
|
"tree/reload",
|
||||||
|
async () => await client.category.getRoot().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const collapseTreeCategory = createAppAsyncThunk(
|
export const collapseTreeCategory = createAppAsyncThunk(
|
||||||
"tree/category/collapse",
|
"tree/category/collapse",
|
||||||
|
|||||||
@@ -297,7 +297,6 @@ export interface LocalSettings {
|
|||||||
|
|
||||||
export interface StarRequest {
|
export interface StarRequest {
|
||||||
id: string
|
id: string
|
||||||
feedId: number
|
|
||||||
starred: boolean
|
starred: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,3 +337,10 @@ export interface AuthenticationError {
|
|||||||
message: string
|
message: string
|
||||||
allowRegistrations: boolean
|
allowRegistrations: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommaFeedExceptionType = "WRONG_USERNAME_OR_PASSWORD"
|
||||||
|
|
||||||
|
export interface CommaFeedApplicationError {
|
||||||
|
type: CommaFeedExceptionType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,26 @@ import { createAppAsyncThunk } from "@/app/async-thunk"
|
|||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import type { Settings } from "@/app/types"
|
import type { Settings } from "@/app/types"
|
||||||
|
|
||||||
export const reloadSettings = createAppAsyncThunk("settings/reload", async () => await client.user.getSettings().then(r => r.data))
|
export const reloadSettings = createAppAsyncThunk(
|
||||||
|
"settings/reload",
|
||||||
|
async () => await client.user.getSettings().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const reloadProfile = createAppAsyncThunk("profile/reload", async () => await client.user.getProfile().then(r => r.data))
|
export const reloadProfile = createAppAsyncThunk(
|
||||||
|
"profile/reload",
|
||||||
|
async () => await client.user.getProfile().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const reloadTags = createAppAsyncThunk("entries/tags", async () => await client.entry.getTags().then(r => r.data))
|
export const reloadTags = createAppAsyncThunk(
|
||||||
|
"entries/tags",
|
||||||
|
async () => await client.entry.getTags().then(r => r.data)
|
||||||
|
)
|
||||||
|
|
||||||
export const changeSettings = createAppAsyncThunk("settings/change", async (newSettings: Partial<Settings>, thunkApi) => {
|
export const changeSettings = createAppAsyncThunk(
|
||||||
const { settings } = thunkApi.getState().user
|
"settings/change",
|
||||||
if (!settings) return
|
async (newSettings: Partial<Settings>, thunkApi) => {
|
||||||
await client.user.saveSettings({ ...settings, ...newSettings })
|
const { settings } = thunkApi.getState().user
|
||||||
})
|
if (!settings) return
|
||||||
|
await client.user.saveSettings({ ...settings, ...newSettings })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -43,13 +43,27 @@ export function categoryHasNewEntries(category?: TreeCategory): boolean {
|
|||||||
.some(f => f.hasNewEntries)
|
.some(f => f.hasNewEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
|
export const calculatePlaceholderSize = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth,
|
||||||
|
}: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
maxWidth: number
|
||||||
|
}) => {
|
||||||
const placeholderWidth = width && Math.min(width, maxWidth)
|
const placeholderWidth = width && Math.min(width, maxWidth)
|
||||||
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
|
||||||
return { width: placeholderWidth, height: placeholderHeight }
|
return { width: placeholderWidth, height: placeholderHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
|
export const scrollToWithCallback = ({
|
||||||
|
options,
|
||||||
|
onScrollEnded,
|
||||||
|
}: {
|
||||||
|
options: ScrollToOptions
|
||||||
|
onScrollEnded: () => void
|
||||||
|
}) => {
|
||||||
const offset = (options.top ?? 0).toFixed()
|
const offset = (options.top ?? 0).toFixed()
|
||||||
|
|
||||||
const onScroll = throttle(100, () => {
|
const onScroll = throttle(100, () => {
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import type { MessageDescriptor } from "@lingui/core"
|
import type { MessageDescriptor } from "@lingui/core"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { ActionIcon, type ActionIconVariant, Box, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
type ActionIconVariant,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
type ButtonVariant,
|
||||||
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
|
} from "@mantine/core"
|
||||||
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
import { forwardRef, type MouseEventHandler, type ReactNode } from "react"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import { useActionButton } from "@/hooks/useActionButton"
|
import { useActionButton } from "@/hooks/useActionButton"
|
||||||
|
|
||||||
|
export type Mode = "auto" | "mobile" | "desktop"
|
||||||
|
|
||||||
interface ActionButtonProps {
|
interface ActionButtonProps {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
label?: string | MessageDescriptor
|
label?: string | MessageDescriptor
|
||||||
onClick?: MouseEventHandler
|
onClick?: MouseEventHandler
|
||||||
variant?: ActionIconVariant & ButtonVariant
|
variant?: ActionIconVariant & ButtonVariant
|
||||||
hideLabelOnDesktop?: boolean
|
mode?: Mode
|
||||||
showLabelOnMobile?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +34,9 @@ export const ActionButton = forwardRef<HTMLDivElement, ActionButtonProps>((props
|
|||||||
|
|
||||||
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
|
||||||
const variant = props.variant ?? "subtle"
|
const variant = props.variant ?? "subtle"
|
||||||
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
|
|
||||||
|
const mode: Mode = props.mode ?? "auto"
|
||||||
|
const iconOnly = mode === "mobile" || (mode === "auto" && mobile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} className="cf-action-button">
|
<Box ref={ref} className="cf-action-button">
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
const source = useAppSelector(state => state.entries.source)
|
const source = useAppSelector(state => state.entries.source)
|
||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) ?? Date.now()
|
||||||
const markAllAsReadNavigateToNextUnread = useAppSelector(state => state.user.settings?.markAllAsReadNavigateToNextUnread)
|
const search = useAppSelector(state => state.entries.search)
|
||||||
|
const markAllAsReadNavigateToNextUnread = useAppSelector(
|
||||||
|
state => state.user.settings?.markAllAsReadNavigateToNextUnread
|
||||||
|
)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
@@ -26,12 +29,14 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
read: true,
|
read: true,
|
||||||
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
|
||||||
insertedBefore: entriesTimestamp,
|
insertedBefore: entriesTimestamp,
|
||||||
|
keywords: search,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
const isAllCategorySelected = source.type === "category" && source.id === Constants.categories.all.id
|
||||||
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected) await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
if (markAllAsReadNavigateToNextUnread && !isAllCategorySelected)
|
||||||
|
await dispatch(selectNextUnreadTreeItem({ direction: "forward" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +54,8 @@ export function MarkAllAsReadConfirmationDialog() {
|
|||||||
)}
|
)}
|
||||||
{threshold > 0 && (
|
{threshold > 0 && (
|
||||||
<Trans>
|
<Trans>
|
||||||
Are you sure you want to mark entries older than {threshold} days of <Code>{sourceLabel}</Code> as read?
|
Are you sure you want to mark entries older than {threshold} days of{" "}
|
||||||
|
<Code>{sourceLabel}</Code> as read?
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -15,5 +15,12 @@ export const ReceivePushNotificationsChechbox = (props: CheckboxProps) => {
|
|||||||
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
description = <Trans>Push notifications are not configured in your user settings.</Trans>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Checkbox label={<Trans>Receive push notifications</Trans>} disabled={disabled} description={description} {...props} />
|
return (
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Receive push notifications</Trans>}
|
||||||
|
disabled={disabled}
|
||||||
|
description={description}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export function UserEdit(props: Readonly<UserEditProps>) {
|
|||||||
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
<form onSubmit={form.onSubmit(saveUser.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<PasswordInput label={<Trans>Password</Trans>} {...form.getInputProps("password")} required={!props.user} />
|
<PasswordInput
|
||||||
|
label={<Trans>Password</Trans>}
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
required={!props.user}
|
||||||
|
/>
|
||||||
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} />
|
||||||
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Admin</Trans>} {...form.getInputProps("admin", { type: "checkbox" })} />
|
||||||
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
<Checkbox label={<Trans>Enabled</Trans>} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe("Content component", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("renders iframe tag when included in content", () => {
|
it("renders iframe tag when included in content", () => {
|
||||||
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, { wrapper: MantineProvider })
|
const { container } = render(<Content content='<iframe src="https://example.com"></iframe>' />, {
|
||||||
|
wrapper: MantineProvider,
|
||||||
|
})
|
||||||
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
expect(container.querySelector("iframe")).toHaveAttribute("src", "https://example.com")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Box, Mark } from "@mantine/core"
|
import { Box, Mark } from "@mantine/core"
|
||||||
import escapeStringRegexp from "escape-string-regexp"
|
import escapeStringRegexp from "escape-string-regexp"
|
||||||
import { ALLOWED_TAG_LIST, type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave"
|
import {
|
||||||
|
ALLOWED_TAG_LIST,
|
||||||
|
type ChildrenNode,
|
||||||
|
Interweave,
|
||||||
|
Matcher,
|
||||||
|
type MatchResponse,
|
||||||
|
type Node,
|
||||||
|
type TransformCallback,
|
||||||
|
} from "interweave"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import styleToObject from "style-to-object"
|
import styleToObject from "style-to-object"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|||||||
@@ -111,12 +111,13 @@ export function FeedEntries() {
|
|||||||
const el = document.getElementById(Constants.dom.entryId(e))
|
const el = document.getElementById(Constants.dom.entryId(e))
|
||||||
return el && !Constants.layout.isTopVisible(el)
|
return el && !Constants.layout.isTopVisible(el)
|
||||||
})
|
})
|
||||||
if (currentEntry) {
|
if (currentEntry && currentEntry.id !== selectedEntryId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
selectEntry({
|
selectEntry({
|
||||||
entry: currentEntry,
|
entry: currentEntry,
|
||||||
expand: false,
|
expand: false,
|
||||||
markAsRead: !!scrollMarks,
|
// don't mark as read entries that the user explicitly marked as unread
|
||||||
|
markAsRead: !!scrollMarks && !currentEntry.keepUnread,
|
||||||
scrollToEntry: false,
|
scrollToEntry: false,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -124,7 +125,7 @@ export function FeedEntries() {
|
|||||||
})
|
})
|
||||||
window.addEventListener("scroll", listener)
|
window.addEventListener("scroll", listener)
|
||||||
return () => window.removeEventListener("scroll", listener)
|
return () => window.removeEventListener("scroll", listener)
|
||||||
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
|
}, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry, selectedEntryId])
|
||||||
|
|
||||||
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
useMousetrap("r", async () => await dispatch(reloadEntries()))
|
||||||
useMousetrap(
|
useMousetrap(
|
||||||
@@ -299,7 +300,9 @@ export function FeedEntries() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
expanded={!!entry.expanded || viewMode === "expanded"}
|
expanded={!!entry.expanded || viewMode === "expanded"}
|
||||||
selected={entry.id === selectedEntryId}
|
selected={entry.id === selectedEntryId}
|
||||||
showSelectionIndicator={entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")}
|
showSelectionIndicator={
|
||||||
|
entry.id === selectedEntryId && (!entry.expanded || viewMode === "expanded")
|
||||||
|
}
|
||||||
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
maxWidth={sidebarVisible ? Constants.layout.entryMaxWidth : undefined}
|
||||||
onHeaderClick={event => headerClicked(entry, event)}
|
onHeaderClick={event => headerClicked(entry, event)}
|
||||||
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
onHeaderRightClick={event => headerRightClicked(entry, event)}
|
||||||
|
|||||||
@@ -34,67 +34,80 @@ const useStyles = tss
|
|||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
fontSizePercentage: number
|
fontSizePercentage: number
|
||||||
}>()
|
}>()
|
||||||
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth, fontSizePercentage }) => {
|
.create(
|
||||||
let backgroundColor: string
|
({
|
||||||
if (colorScheme === "dark") {
|
theme,
|
||||||
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
colorScheme,
|
||||||
} else {
|
read,
|
||||||
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
expanded,
|
||||||
}
|
viewMode,
|
||||||
|
rtl,
|
||||||
|
showSelectionIndicator,
|
||||||
|
maxWidth,
|
||||||
|
fontSizePercentage,
|
||||||
|
}) => {
|
||||||
|
let backgroundColor: string
|
||||||
|
if (colorScheme === "dark") {
|
||||||
|
backgroundColor = read ? "inherit" : theme.colors.dark[5]
|
||||||
|
} else {
|
||||||
|
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
|
||||||
|
}
|
||||||
|
|
||||||
let marginY = 10
|
let marginY = 10
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
marginY = 2
|
marginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
marginY = 6
|
marginY = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileMarginY = 6
|
let mobileMarginY = 6
|
||||||
if (viewMode === "title") {
|
if (viewMode === "title") {
|
||||||
mobileMarginY = 2
|
mobileMarginY = 2
|
||||||
} else if (viewMode === "cozy") {
|
} else if (viewMode === "cozy") {
|
||||||
mobileMarginY = 4
|
mobileMarginY = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundHoverColor = backgroundColor
|
let backgroundHoverColor = backgroundColor
|
||||||
if (!expanded && !read) {
|
if (!expanded && !read) {
|
||||||
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
let paperBorderLeftColor = ""
|
let paperBorderLeftColor = ""
|
||||||
if (showSelectionIndicator) {
|
if (showSelectionIndicator) {
|
||||||
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
const borderLeftColor =
|
||||||
paperBorderLeftColor = `${borderLeftColor} !important`
|
colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
|
||||||
}
|
paperBorderLeftColor = `${borderLeftColor} !important`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paper: {
|
paper: {
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
borderLeftColor: paperBorderLeftColor,
|
borderLeftColor: paperBorderLeftColor,
|
||||||
marginTop: marginY,
|
marginTop: marginY,
|
||||||
marginBottom: marginY,
|
marginBottom: marginY,
|
||||||
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
|
||||||
marginTop: mobileMarginY,
|
marginTop: mobileMarginY,
|
||||||
marginBottom: mobileMarginY,
|
marginBottom: mobileMarginY,
|
||||||
},
|
},
|
||||||
"@media (hover: hover)": {
|
"@media (hover: hover)": {
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: backgroundHoverColor,
|
backgroundColor: backgroundHoverColor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
headerLink: {
|
||||||
headerLink: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
fontSize: `${fontSizePercentage}%`,
|
color: "inherit",
|
||||||
color: "inherit",
|
textDecoration: "none",
|
||||||
textDecoration: "none",
|
},
|
||||||
},
|
body: {
|
||||||
body: {
|
fontSize: `${fontSizePercentage}%`,
|
||||||
fontSize: `${fontSizePercentage}%`,
|
direction: rtl ? "rtl" : "ltr",
|
||||||
direction: rtl ? "rtl" : "ltr",
|
maxWidth: maxWidth ?? "100%",
|
||||||
maxWidth: maxWidth ?? "100%",
|
},
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
||||||
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
|
||||||
@@ -116,7 +129,9 @@ export function FeedEntry(props: Readonly<FeedEntryProps>) {
|
|||||||
const showExternalLinkIcon =
|
const showExternalLinkIcon =
|
||||||
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
|
||||||
const showStarIcon =
|
const showStarIcon =
|
||||||
props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
props.entry.markable &&
|
||||||
|
starIconDisplayMode &&
|
||||||
|
["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
|
||||||
|
|
||||||
const swipeHandlers = useSwipeable({
|
const swipeHandlers = useSwipeable({
|
||||||
onSwipedLeft: props.onSwipedLeft,
|
onSwipedLeft: props.onSwipedLeft,
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
|||||||
const { openLinkInBackgroundTab } = useBrowserExtension()
|
const { openLinkInBackgroundTab } = useBrowserExtension()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu id={Constants.dom.entryContextMenuId(props.entry)} theme={colorScheme} animation={false} className={classes.menu}>
|
<Menu
|
||||||
|
id={Constants.dom.entryContextMenuId(props.entry)}
|
||||||
|
theme={colorScheme}
|
||||||
|
animation={false}
|
||||||
|
className={classes.menu}
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(props.entry.url, "_blank", "noreferrer")
|
window.open(props.entry.url, "_blank", "noreferrer")
|
||||||
@@ -63,13 +68,19 @@ export function FeedEntryContextMenu(props: Readonly<FeedEntryContextMenuProps>)
|
|||||||
|
|
||||||
{props.entry.markable && (
|
{props.entry.markable && (
|
||||||
<>
|
<>
|
||||||
<Item onClick={async () => await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))}>
|
<Item
|
||||||
|
onClick={async () =>
|
||||||
|
await dispatch(starEntry({ entry: props.entry, starred: !props.entry.starred }))
|
||||||
|
}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
{props.entry.starred ? <TbStarOff size={iconSize} /> : <TbStar size={iconSize} />}
|
||||||
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
{props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
</Group>
|
</Group>
|
||||||
</Item>
|
</Item>
|
||||||
<Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
|
<Item
|
||||||
|
onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
{props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
|
||||||
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
|
||||||
import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
|
import {
|
||||||
|
TbArrowBarToDown,
|
||||||
|
TbExternalLink,
|
||||||
|
TbMail,
|
||||||
|
TbMailOpened,
|
||||||
|
TbShare,
|
||||||
|
TbStar,
|
||||||
|
TbStarOff,
|
||||||
|
TbTag,
|
||||||
|
} from "react-icons/tb"
|
||||||
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "@/app/entries/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import type { Entry } from "@/app/types"
|
import type { Entry } from "@/app/types"
|
||||||
@@ -71,7 +80,12 @@ export function FeedEntryFooter(props: Readonly<FeedEntryFooterProps>) {
|
|||||||
{tags && (
|
{tags && (
|
||||||
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
|
<Indicator
|
||||||
|
label={props.entry.tags.length}
|
||||||
|
disabled={props.entry.tags.length === 0}
|
||||||
|
inline
|
||||||
|
size={16}
|
||||||
|
>
|
||||||
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
<ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
|
||||||
</Indicator>
|
</Indicator>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ export function ShareButtons(
|
|||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
|
||||||
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
|
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(
|
||||||
|
site => sharingSettings?.[site]
|
||||||
|
)
|
||||||
const url = encodeURIComponent(props.url)
|
const url = encodeURIComponent(props.url)
|
||||||
const desc = encodeURIComponent(props.description)
|
const desc = encodeURIComponent(props.description)
|
||||||
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
|
||||||
@@ -119,7 +121,9 @@ export function ShareButtons(
|
|||||||
{showNativeSection && (
|
{showNativeSection && (
|
||||||
<SimpleGrid cols={4}>
|
<SimpleGrid cols={4}>
|
||||||
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
{clipboardAvailable && <CopyUrlButton url={props.url} />}
|
||||||
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
|
{nativeSharingAvailable && (
|
||||||
|
<BrowserNativeShareButton url={props.url} description={props.description} />
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export function AddCategory() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
<form onSubmit={form.onSubmit(addCategory.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
|
<TextInput
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
placeholder={_(msg`Category`)}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export function ImportOpml() {
|
|||||||
placeholder={_(msg`OPML file`)}
|
placeholder={_(msg`OPML file`)}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
|
An opml file is an XML file containing feed URLs and categories. You can get an OPML
|
||||||
data from other feed reading services.
|
file by exporting your data from other feed reading services.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
{...form.getInputProps("file")}
|
{...form.getInputProps("file")}
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ export function Subscribe() {
|
|||||||
placeholder="https://www.mysite.com/rss"
|
placeholder="https://www.mysite.com/rss"
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
The URL for the feed you want to subscribe to. You can also use the website's url directly and CommaFeed
|
The URL for the feed you want to subscribe to. You can also use the website's url
|
||||||
will try to find the feed in the page.
|
directly and CommaFeed will try to find the feed in the page.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
@@ -101,8 +101,17 @@ export function Subscribe() {
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
<TextInput label={<Trans>Feed URL</Trans>} {...step1Form.getInputProps("url")} disabled />
|
||||||
<TextInput label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
|
<TextInput
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
|
label={<Trans>Feed name</Trans>}
|
||||||
|
{...step1Form.getInputProps("title")}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<CategorySelect
|
||||||
|
label={<Trans>Category</Trans>}
|
||||||
|
{...step1Form.getInputProps("categoryId")}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@@ -117,7 +126,11 @@ export function Subscribe() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<Button type="submit" leftSection={<TbRss size={16} />} loading={fetchFeed.loading || subscribe.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbRss size={16} />}
|
||||||
|
loading={fetchFeed.loading || subscribe.loading}
|
||||||
|
>
|
||||||
<Trans>Subscribe</Trans>
|
<Trans>Subscribe</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ const fields: Field[] = [
|
|||||||
{ name: "categoriesLower", label: "Categories (lower case)" },
|
{ name: "categoriesLower", label: "Categories (lower case)" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const textOperators = new Set(["=", "!=", "contains", "beginsWith", "endsWith", "doesNotContain", "doesNotBeginWith", "doesNotEndWith"])
|
const textOperators = new Set([
|
||||||
|
"=",
|
||||||
|
"!=",
|
||||||
|
"contains",
|
||||||
|
"beginsWith",
|
||||||
|
"endsWith",
|
||||||
|
"doesNotContain",
|
||||||
|
"doesNotBeginWith",
|
||||||
|
"doesNotEndWith",
|
||||||
|
])
|
||||||
|
|
||||||
function toCelString(query: RuleGroupType): string {
|
function toCelString(query: RuleGroupType): string {
|
||||||
if (query.rules.length === 0) {
|
if (query.rules.length === 0) {
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function Star(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
|
<Tooltip
|
||||||
|
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>}
|
||||||
|
openDelay={Constants.tooltip.delay}
|
||||||
|
>
|
||||||
<ActionIcon variant="transparent" onClick={onClick}>
|
<ActionIcon variant="transparent" onClick={onClick}>
|
||||||
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { msg } from "@lingui/core/macro"
|
|||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useEffect } from "react"
|
import { type ReactNode, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
TbArrowDown,
|
TbArrowDown,
|
||||||
TbArrowUp,
|
TbArrowUp,
|
||||||
@@ -17,7 +17,13 @@ import {
|
|||||||
TbSortDescending,
|
TbSortDescending,
|
||||||
TbUser,
|
TbUser,
|
||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { markAllAsReadWithConfirmationIfRequired, reloadEntries, search, selectNextEntry, selectPreviousEntry } from "@/app/entries/thunks"
|
import {
|
||||||
|
markAllAsReadWithConfirmationIfRequired,
|
||||||
|
reloadEntries,
|
||||||
|
search,
|
||||||
|
selectNextEntry,
|
||||||
|
selectPreviousEntry,
|
||||||
|
} from "@/app/entries/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import { changeSettings } from "@/app/user/thunks"
|
import { changeSettings } from "@/app/user/thunks"
|
||||||
import { ActionButton } from "@/components/ActionButton"
|
import { ActionButton } from "@/components/ActionButton"
|
||||||
@@ -31,7 +37,7 @@ function HeaderDivider() {
|
|||||||
return <Divider orientation="vertical" />
|
return <Divider orientation="vertical" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderToolbar(props: { children: React.ReactNode }) {
|
function HeaderToolbar(props: { children: ReactNode }) {
|
||||||
const { spacing } = useActionButton()
|
const { spacing } = useActionButton()
|
||||||
const mobile = useMobile("480px")
|
const mobile = useMobile("480px")
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
@@ -47,7 +53,7 @@ function HeaderToolbar(props: { children: React.ReactNode }) {
|
|||||||
{props.children}
|
{props.children}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={spacing} className="cf-toolbar">
|
<Group gap={spacing} wrap="nowrap" className="cf-toolbar">
|
||||||
{props.children}
|
{props.children}
|
||||||
</Group>
|
</Group>
|
||||||
)
|
)
|
||||||
@@ -143,7 +149,13 @@ export function Header() {
|
|||||||
onClick={toggleReadingMode}
|
onClick={toggleReadingMode}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
|
icon={
|
||||||
|
settings.readingOrder === "asc" ? (
|
||||||
|
<TbSortAscending size={iconSize} />
|
||||||
|
) : (
|
||||||
|
<TbSortDescending size={iconSize} />
|
||||||
|
)
|
||||||
|
}
|
||||||
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
|
||||||
onClick={toggleReadingOrder}
|
onClick={toggleReadingOrder}
|
||||||
/>
|
/>
|
||||||
@@ -160,7 +172,11 @@ export function Header() {
|
|||||||
placeholder={_(msg`Search`)}
|
placeholder={_(msg`Search`)}
|
||||||
{...searchForm.getInputProps("search")}
|
{...searchForm.getInputProps("search")}
|
||||||
leftSection={<TbSearch size={iconSize} />}
|
leftSection={<TbSearch size={iconSize} />}
|
||||||
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
|
rightSection={
|
||||||
|
<CloseButton
|
||||||
|
onClick={async () => await (searchFromStore && dispatch(search("")))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ import {
|
|||||||
} from "react-icons/tb"
|
} from "react-icons/tb"
|
||||||
import { throttle } from "throttle-debounce"
|
import { throttle } from "throttle-debounce"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import { redirectToAbout, redirectToAdminUsers, redirectToDonate, redirectToMetrics, redirectToSettings } from "@/app/redirect/thunks"
|
import {
|
||||||
|
redirectToAbout,
|
||||||
|
redirectToAdminUsers,
|
||||||
|
redirectToDonate,
|
||||||
|
redirectToMetrics,
|
||||||
|
redirectToSettings,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import type { ViewMode } from "@/app/types"
|
import type { ViewMode } from "@/app/types"
|
||||||
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
import { setFontSizePercentage, setViewMode } from "@/app/user/slice"
|
||||||
@@ -124,7 +130,13 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened} preventPositionChangeWhenVisible={false}>
|
<Menu
|
||||||
|
position="bottom-end"
|
||||||
|
closeOnItemClick={false}
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
preventPositionChangeWhenVisible={false}
|
||||||
|
>
|
||||||
<Menu.Target>{props.control}</Menu.Target>
|
<Menu.Target>{props.control}</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
{profile && <Menu.Label>{profile.name}</Menu.Label>}
|
||||||
@@ -164,7 +176,9 @@ export function ProfileMenu(props: Readonly<ProfileMenuProps>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Fetch all my feeds now</Trans>
|
<Trans>Fetch all my feeds now</Trans>
|
||||||
{!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
|
{!forceRefreshEnabled && (
|
||||||
|
<span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>
|
||||||
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ export function CustomCodeSettings() {
|
|||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveCustomCode.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={saveCustomCode.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -111,7 +111,11 @@ export function DisplaySettings() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={<Trans>Navigate to the next category/feed with unread entries when marking all entries as read</Trans>}
|
label={
|
||||||
|
<Trans>
|
||||||
|
Navigate to the next category/feed with unread entries when marking all entries as read
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
checked={settings.markAllAsReadNavigateToNextUnread}
|
checked={settings.markAllAsReadNavigateToNextUnread}
|
||||||
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
onChange={e => dispatch(changeSettings({ markAllAsReadNavigateToNextUnread: e.currentTarget.checked }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -91,8 +103,9 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>API key</Trans>}
|
label={<Trans>API key</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
This is your API key. It can be used for some read-only API operations and grants access to the Fever API
|
This is your API key. It can be used for some read-only API operations and grants access
|
||||||
and the Google Reader API. Use the form at the bottom of the page to generate a new API key
|
to the Fever API and the Google Reader API. Use the form at the bottom of the page to
|
||||||
|
generate a new API key
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -103,7 +116,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>OPML export</Trans>}
|
label={<Trans>OPML export</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
|
Export your subscriptions and categories as an OPML file that can be imported in other
|
||||||
|
feed reading services
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -118,8 +132,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Fever API</Trans>}
|
label={<Trans>Fever API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
|
CommaFeed is compatible with the Fever API. Use the following URL in your
|
||||||
Login with your username and your <u>API key</u>.
|
Fever-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -134,8 +148,8 @@ export function ProfileSettings() {
|
|||||||
label={<Trans>Google Reader API</Trans>}
|
label={<Trans>Google Reader API</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google Reader-compatible
|
CommaFeed is compatible with the Google Reader API. Use the following URL in your Google
|
||||||
mobile client. Login with your username and your <u>API key</u>.
|
Reader-compatible mobile client. Login with your username and your <u>API key</u>.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -165,8 +179,14 @@ export function ProfileSettings() {
|
|||||||
description={<Trans>Changing password will generate a new API key</Trans>}
|
description={<Trans>Changing password will generate a new API key</Trans>}
|
||||||
{...form.getInputProps("newPassword")}
|
{...form.getInputProps("newPassword")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
|
<PasswordInput
|
||||||
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
|
label={<Trans>Confirm password</Trans>}
|
||||||
|
{...form.getInputProps("newPasswordConfirmation")}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={<Trans>Generate new API key</Trans>}
|
||||||
|
{...form.getInputProps("newApiKey", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function PushNotificationSettings() {
|
|||||||
label={<Trans>Push notification service</Trans>}
|
label={<Trans>Push notification service</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Receive push notifications when new feed entries are discovered. Enable "Receive push notifications" in the
|
Receive push notifications when new feed entries are discovered. Enable "Receive push
|
||||||
settings of each feed for which you want to receive notifications.
|
notifications" in the settings of each feed for which you want to receive notifications.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
data={[
|
data={[
|
||||||
@@ -77,7 +77,12 @@ export function PushNotificationSettings() {
|
|||||||
required
|
required
|
||||||
{...form.getInputProps("serverUrl")}
|
{...form.getInputProps("serverUrl")}
|
||||||
/>
|
/>
|
||||||
<TextInput label={<Trans>Topic</Trans>} placeholder="commafeed" required {...form.getInputProps("topic")} />
|
<TextInput
|
||||||
|
label={<Trans>Topic</Trans>}
|
||||||
|
placeholder="commafeed"
|
||||||
|
required
|
||||||
|
{...form.getInputProps("topic")}
|
||||||
|
/>
|
||||||
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
<TextInput label={<Trans>Access token</Trans>} {...form.getInputProps("userSecret")} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -119,8 +119,11 @@ export function Tree() {
|
|||||||
const categoryNode = (category: Category, level = 0) => {
|
const categoryNode = (category: Category, level = 0) => {
|
||||||
if (!isCategoryDisplayed(category)) return null
|
if (!isCategoryDisplayed(category)) return null
|
||||||
|
|
||||||
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
const hasError =
|
||||||
const hasWarning = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
!category.expanded &&
|
||||||
|
flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
|
||||||
|
const hasWarning =
|
||||||
|
!category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => !!f.filterLegacy))
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
id={category.id}
|
id={category.id}
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ export function TreeNode(props: Readonly<TreeNodeProps>) {
|
|||||||
data-type={props.type}
|
data-type={props.type}
|
||||||
data-unread-count={props.unread}
|
data-unread-count={props.unread}
|
||||||
>
|
>
|
||||||
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
|
<Box
|
||||||
|
mr={6}
|
||||||
|
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
|
||||||
|
className="cf-treenode-icon"
|
||||||
|
>
|
||||||
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
|
||||||
</Box>
|
</Box>
|
||||||
<Box className={classes.nodeText}>{props.name}</Box>
|
<Box className={classes.nodeText}>{props.name}</Box>
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "موقع الكتروني"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
|
msgstr "مرحباً! يبدو أن هذه هي المرة الأولى التي تقوم فيها بتشغيل CommaFeed. يرجى إنشاء حساب مسؤول للبدء."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "أصفر"
|
msgstr "أصفر"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Lloc web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Benvingut! Sembla que aquesta és la primera vegada que executeu CommaFeed. Creeu un compte d'administrador per començar."
|
msgstr "Benvingut! Sembla que aquesta és la primera vegada que executeu CommaFeed. Creeu un compte d'administrador per començar."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Groc"
|
msgstr "Groc"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Webová stránka"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Vítejte! Zdá se, že je to poprvé, co spouštíte CommaFeed. Chcete-li začít, vytvořte si účet správce."
|
msgstr "Vítejte! Zdá se, že je to poprvé, co spouštíte CommaFeed. Chcete-li začít, vytvořte si účet správce."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Žlutá"
|
msgstr "Žlutá"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Gwefan"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Croeso! Ymddengys mai dyma'r tro cyntaf i chi redeg CommaFeed. Creuwch gyfrif gweinyddwr i ddechrau."
|
msgstr "Croeso! Ymddengys mai dyma'r tro cyntaf i chi redeg CommaFeed. Creuwch gyfrif gweinyddwr i ddechrau."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Melyn"
|
msgstr "Melyn"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Hjemmeside"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Velkommen! Det ser ud til at være første gang, du kører CommaFeed. Opret venligst en administratorkonto for at komme i gang."
|
msgstr "Velkommen! Det ser ud til at være første gang, du kører CommaFeed. Opret venligst en administratorkonto for at komme i gang."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Gul"
|
msgstr "Gul"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Webseite"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Willkommen! Dies scheint das erste Mal zu sein, dass Sie CommaFeed ausführen. Bitte erstellen Sie ein Administrator-Konto, um zu beginnen."
|
msgstr "Willkommen! Dies scheint das erste Mal zu sein, dass Sie CommaFeed ausführen. Bitte erstellen Sie ein Administrator-Konto, um zu beginnen."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Gelb"
|
msgstr "Gelb"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Website"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgstr "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr "Wrong username or password"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Yellow"
|
msgstr "Yellow"
|
||||||
|
|||||||
@@ -1151,6 +1151,10 @@ msgstr "Sitio web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "¡Bienvenido! Parece que esta es la primera vez que ejecutas CommaFeed. Por favor, crea una cuenta de administrador para empezar."
|
msgstr "¡Bienvenido! Parece que esta es la primera vez que ejecutas CommaFeed. Por favor, crea una cuenta de administrador para empezar."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Amarillo"
|
msgstr "Amarillo"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "وب سایت"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "خوش آمدید! به نظر میرسد این اولین بار است که CommaFeed را اجرا میکنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید."
|
msgstr "خوش آمدید! به نظر میرسد این اولین بار است که CommaFeed را اجرا میکنید. لطفاً برای شروع یک حساب مدیر ایجاد کنید."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "زرد"
|
msgstr "زرد"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Verkkosivusto"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Tervetuloa! Näyttää siltä, että suoritat CommaFeediä ensimmäistä kertaa. Luo ylläpitäjän tili aloittaaksesi."
|
msgstr "Tervetuloa! Näyttää siltä, että suoritat CommaFeediä ensimmäistä kertaa. Luo ylläpitäjän tili aloittaaksesi."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Keltainen"
|
msgstr "Keltainen"
|
||||||
|
|||||||
@@ -460,11 +460,11 @@ msgstr "Extensions"
|
|||||||
|
|
||||||
#: src/components/settings/ProfileSettings.tsx
|
#: src/components/settings/ProfileSettings.tsx
|
||||||
msgid "Google Reader API"
|
msgid "Google Reader API"
|
||||||
msgstr ""
|
msgstr "API Google Reader"
|
||||||
|
|
||||||
#: src/components/settings/ProfileSettings.tsx
|
#: src/components/settings/ProfileSettings.tsx
|
||||||
msgid "Google Reader API URL"
|
msgid "Google Reader API URL"
|
||||||
msgstr ""
|
msgstr "URL de l'API Google Reader"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Grape"
|
msgid "Grape"
|
||||||
@@ -1150,6 +1150,10 @@ msgstr "Site web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Bienvenue ! Il semble que ce soit le premier démarrage de Commafeed. Avant tout, vous devez créer un compte administrateur."
|
msgstr "Bienvenue ! Il semble que ce soit le premier démarrage de Commafeed. Avant tout, vous devez créer un compte administrateur."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr "Utilisateur ou mot de passe incorrect"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Jaune"
|
msgstr "Jaune"
|
||||||
|
|||||||
@@ -1151,6 +1151,10 @@ msgstr "Páxina web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Benvida! Semella que é a primeira vez que executas CommaFeed. Para comezar, crea unha conta de administración."
|
msgstr "Benvida! Semella que é a primeira vez que executas CommaFeed. Para comezar, crea unha conta de administración."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Amarelo"
|
msgstr "Amarelo"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Webhely"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Üdvözöljük! Úgy tűnik, ez az első alkalom, hogy a CommaFeedet futtatja. Kérjük, hozzon létre egy rendszergazdai fiókot a kezdéshez."
|
msgstr "Üdvözöljük! Úgy tűnik, ez az első alkalom, hogy a CommaFeedet futtatja. Kérjük, hozzon létre egy rendszergazdai fiókot a kezdéshez."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Sárga"
|
msgstr "Sárga"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Situs Web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Selamat datang! Ini sepertinya pertama kalinya Anda menjalankan CommaFeed. Silakan buat akun administrator untuk memulai."
|
msgstr "Selamat datang! Ini sepertinya pertama kalinya Anda menjalankan CommaFeed. Silakan buat akun administrator untuk memulai."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Kuning"
|
msgstr "Kuning"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Sito web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Benvenuto! Sembra che sia la prima volta che esegui CommaFeed. Crea un account amministratore per iniziare."
|
msgstr "Benvenuto! Sembra che sia la prima volta che esegui CommaFeed. Crea un account amministratore per iniziare."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Giallo"
|
msgstr "Giallo"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "ウェブサイト"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。"
|
msgstr "ようこそ!CommaFeedを初めて実行するようです。開始するには管理者アカウントを作成してください。"
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "イエロー"
|
msgstr "イエロー"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "웹사이트"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오."
|
msgstr "환영합니다! CommaFeed를 처음 실행하는 것 같습니다. 시작하려면 관리자 계정을 만드십시오."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "옐로우"
|
msgstr "옐로우"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Laman web"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Selamat datang! Ini nampaknya kali pertama anda menjalankan CommaFeed. Sila cipta akaun pentadbir untuk bermula."
|
msgstr "Selamat datang! Ini nampaknya kali pertama anda menjalankan CommaFeed. Sila cipta akaun pentadbir untuk bermula."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Kuning"
|
msgstr "Kuning"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Nettsted"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Velkommen! Det ser ut til å være første gang du kjører CommaFeed. Vennligst opprett en administratorkonto for å komme i gang."
|
msgstr "Velkommen! Det ser ut til å være første gang du kjører CommaFeed. Vennligst opprett en administratorkonto for å komme i gang."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Gul"
|
msgstr "Gul"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Website"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Welkom! Dit lijkt de eerste keer te zijn dat je CommaFeed draait. Maak een beheerdersaccount aan om aan de slag te gaan."
|
msgstr "Welkom! Dit lijkt de eerste keer te zijn dat je CommaFeed draait. Maak een beheerdersaccount aan om aan de slag te gaan."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Geel"
|
msgstr "Geel"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Nettstad"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Velkomen! Det ser ut til å vera fyrste gongen du køyrer CommaFeed. Ver venleg og opprett ein administratorkonto for å koma i gang."
|
msgstr "Velkomen! Det ser ut til å vera fyrste gongen du køyrer CommaFeed. Ver venleg og opprett ein administratorkonto for å koma i gang."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Gul"
|
msgstr "Gul"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Strona internetowa"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Witaj! Wygląda na to, że uruchamiasz CommaFeed po raz pierwszy. Aby rozpocząć, utwórz konto administratora."
|
msgstr "Witaj! Wygląda na to, że uruchamiasz CommaFeed po raz pierwszy. Aby rozpocząć, utwórz konto administratora."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Żółty"
|
msgstr "Żółty"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Site"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Bem-vindo! Esta parece ser a primeira vez que você está executando o CommaFeed. Crie uma conta de administrador para começar."
|
msgstr "Bem-vindo! Esta parece ser a primeira vez que você está executando o CommaFeed. Crie uma conta de administrador para começar."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Amarelo"
|
msgstr "Amarelo"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Веб-сайт"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу."
|
msgstr "Добро пожаловать! Похоже, вы запускаете CommaFeed в первый раз. Пожалуйста, создайте учетную запись администратора, чтобы начать работу."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Желтый"
|
msgstr "Желтый"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Webová stránka"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Vitajte! Zdá sa, že CommaFeed spúšťate prvýkrát. Ak chcete začať, vytvorte si účet správcu."
|
msgstr "Vitajte! Zdá sa, že CommaFeed spúšťate prvýkrát. Ak chcete začať, vytvorte si účet správcu."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Žltá"
|
msgstr "Žltá"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Webbplats"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Välkommen! Det verkar vara första gången du kör CommaFeed. Skapa ett administratörskonto för att komma igång."
|
msgstr "Välkommen! Det verkar vara första gången du kör CommaFeed. Skapa ett administratörskonto för att komma igång."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Gul"
|
msgstr "Gul"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "Web sitesi"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "Hoş geldiniz! Görünüşe göre CommaFeed'i ilk kez çalıştırıyorsunuz. Başlamak için lütfen bir yönetici hesabı oluşturun."
|
msgstr "Hoş geldiniz! Görünüşe göre CommaFeed'i ilk kez çalıştırıyorsunuz. Başlamak için lütfen bir yönetici hesabı oluşturun."
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "Sarı"
|
msgstr "Sarı"
|
||||||
|
|||||||
@@ -1150,6 +1150,10 @@ msgstr "网站"
|
|||||||
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
msgid "Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get started."
|
||||||
msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。"
|
msgstr "欢迎!当前页仅当您第一次使用CommaFeed时出现,请创建一个管理员帐号以开始使用。"
|
||||||
|
|
||||||
|
#: src/app/client.ts
|
||||||
|
msgid "Wrong username or password"
|
||||||
|
msgstr "用户名或密码错误"
|
||||||
|
|
||||||
#: src/components/settings/DisplaySettings.tsx
|
#: src/components/settings/DisplaySettings.tsx
|
||||||
msgid "Yellow"
|
msgid "Yellow"
|
||||||
msgstr "黄"
|
msgstr "黄"
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Space,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { SiGithub, SiX } from "react-icons/si"
|
import { SiGithub, SiX } from "react-icons/si"
|
||||||
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
|
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
|
||||||
import { client } from "@/app/client"
|
import { client } from "@/app/client"
|
||||||
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "@/app/redirect/thunks"
|
import {
|
||||||
|
redirectToApiDocumentation,
|
||||||
|
redirectToLogin,
|
||||||
|
redirectToRegistration,
|
||||||
|
redirectToRootCategory,
|
||||||
|
} from "@/app/redirect/thunks"
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||||
import welcomePageDark from "@/assets/welcome_page_dark.png"
|
import welcomePageDark from "@/assets/welcome_page_dark.png"
|
||||||
import welcomePageLight from "@/assets/welcome_page_light.png"
|
import welcomePageLight from "@/assets/welcome_page_light.png"
|
||||||
@@ -42,7 +58,7 @@ export function WelcomePage() {
|
|||||||
icon={<TbClock size={iconSize} />}
|
icon={<TbClock size={iconSize} />}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
|
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
|
||||||
showLabelOnMobile
|
mode="desktop"
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
@@ -100,7 +116,7 @@ function Buttons() {
|
|||||||
icon={<TbKey size={iconSize} />}
|
icon={<TbKey size={iconSize} />}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={async () => await dispatch(redirectToLogin())}
|
onClick={async () => await dispatch(redirectToLogin())}
|
||||||
showLabelOnMobile
|
mode="desktop"
|
||||||
/>
|
/>
|
||||||
{serverInfos?.allowRegistrations && (
|
{serverInfos?.allowRegistrations && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -108,7 +124,7 @@ function Buttons() {
|
|||||||
icon={<TbUserPlus size={iconSize} />}
|
icon={<TbUserPlus size={iconSize} />}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={async () => await dispatch(redirectToRegistration())}
|
onClick={async () => await dispatch(redirectToRegistration())}
|
||||||
showLabelOnMobile
|
mode="desktop"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -116,7 +132,7 @@ function Buttons() {
|
|||||||
label={dark ? msg`Switch to light theme` : msg`Switch to dark theme`}
|
label={dark ? msg`Switch to light theme` : msg`Switch to dark theme`}
|
||||||
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
|
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
|
||||||
onClick={() => toggleColorScheme()}
|
onClick={() => toggleColorScheme()}
|
||||||
hideLabelOnDesktop
|
mode="mobile"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isBrowserExtensionPopup && (
|
{isBrowserExtensionPopup && (
|
||||||
@@ -124,7 +140,7 @@ function Buttons() {
|
|||||||
label={msg`Extension options`}
|
label={msg`Extension options`}
|
||||||
icon={<TbSettings size={iconSize} />}
|
icon={<TbSettings size={iconSize} />}
|
||||||
onClick={() => openSettingsPage()}
|
onClick={() => openSettingsPage()}
|
||||||
hideLabelOnDesktop
|
mode="mobile"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export function AdminUsersPage() {
|
|||||||
<Title order={3} mb="md">
|
<Title order={3} mb="md">
|
||||||
<Group>
|
<Group>
|
||||||
<Trans>Manage users</Trans>
|
<Trans>Manage users</Trans>
|
||||||
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
|
<ActionIcon
|
||||||
|
color={theme.primaryColor}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => openUserEditModal(<Trans>Add user</Trans>)}
|
||||||
|
>
|
||||||
<TbPlus size={20} />
|
<TbPlus size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ const shownGauges: Record<string, string> = {
|
|||||||
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
|
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Feed Refresh Engine queue size",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Refresh Engine active HTTP workers",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Refresh Engine active database update workers",
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active": "Feed Refresh Engine active push notifications workers",
|
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.active":
|
||||||
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue": "Feed Refresh Engine queued push notifications workers",
|
"Feed Refresh Engine active push notifications workers",
|
||||||
|
"com.commafeed.backend.feed.FeedRefreshEngine.notifier.queue":
|
||||||
|
"Feed Refresh Engine queued push notifications workers",
|
||||||
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
|
"com.commafeed.backend.HttpGetter.cache.size": "HttpGetter cached entries",
|
||||||
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
|
"com.commafeed.backend.HttpGetter.cache.memoryUsage": "HttpGetter cache memory usage",
|
||||||
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
|
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ function NextUnreadBookmarklet() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
|
<CategorySelect
|
||||||
|
value={categoryId}
|
||||||
|
onChange={c => c && setCategoryId(c)}
|
||||||
|
withAll
|
||||||
|
description={<Trans>Category</Trans>}
|
||||||
|
/>
|
||||||
<NativeSelect
|
<NativeSelect
|
||||||
data={[
|
data={[
|
||||||
{ value: "desc", label: _(msg`Newest first`) },
|
{ value: "desc", label: _(msg`Newest first`) },
|
||||||
@@ -73,7 +78,8 @@ function NextUnreadBookmarklet() {
|
|||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
const version = useAppSelector(state => state.server.serverInfos?.version)
|
const version = useAppSelector(state => state.server.serverInfos?.version)
|
||||||
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
|
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
|
||||||
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
|
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } =
|
||||||
|
useBrowserExtension()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +106,9 @@ export function AboutPage() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
|
<Trans>
|
||||||
|
If you encounter an issue, please report it on the issues page of the GitHub project.
|
||||||
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
|
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Code,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { openConfirmModal } from "@mantine/modals"
|
import { openConfirmModal } from "@mantine/modals"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -37,6 +51,9 @@ export function CategoryDetailsPage() {
|
|||||||
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
|
category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a category cannot be moved under itself or one of its own descendants
|
||||||
|
const excludedCategoryIds = category ? flattenCategoryTree(category).map(c => c.id) : [id]
|
||||||
|
|
||||||
const form = useForm<CategoryModificationRequest>()
|
const form = useForm<CategoryModificationRequest>()
|
||||||
const { setValues } = form
|
const { setValues } = form
|
||||||
|
|
||||||
@@ -125,9 +142,14 @@ export function CategoryDetailsPage() {
|
|||||||
label={<Trans>Parent Category</Trans>}
|
label={<Trans>Parent Category</Trans>}
|
||||||
{...form.getInputProps("parentId")}
|
{...form.getInputProps("parentId")}
|
||||||
clearable
|
clearable
|
||||||
withoutCategoryIds={[id]}
|
withoutCategoryIds={excludedCategoryIds}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<Trans>Position</Trans>}
|
||||||
|
{...form.getInputProps("position")}
|
||||||
|
required
|
||||||
|
min={0}
|
||||||
/>
|
/>
|
||||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -137,7 +159,11 @@ export function CategoryDetailsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{editable && (
|
{editable && (
|
||||||
<>
|
<>
|
||||||
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<TbDeviceFloppy size={16} />}
|
||||||
|
loading={modifyCategory.loading}
|
||||||
|
>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Divider orientation="vertical" />
|
<Divider orientation="vertical" />
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function DonatePage() {
|
|||||||
<Trans>
|
<Trans>
|
||||||
<Box>Hey,</Box>
|
<Box>Hey,</Box>
|
||||||
<Box mt="xs">
|
<Box mt="xs">
|
||||||
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years now. Thanks for taking
|
I'm Jérémie from Belgium and I've been working on CommaFeed in my free time for over 10 years
|
||||||
an interest in helping me continue supporting CommaFeed.
|
now. Thanks for taking an interest in helping me continue supporting CommaFeed.
|
||||||
</Box>
|
</Box>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export function FeedDetailsPage() {
|
|||||||
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
|
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
|
||||||
<Box>
|
<Box>
|
||||||
{apiKey && (
|
{apiKey && (
|
||||||
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
|
<Anchor
|
||||||
|
href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
<Trans>Link</Trans>
|
<Trans>Link</Trans>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
@@ -146,10 +150,16 @@ export function FeedDetailsPage() {
|
|||||||
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
|
||||||
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
|
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
|
||||||
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
|
||||||
<ReceivePushNotificationsChechbox {...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })} />
|
<ReceivePushNotificationsChechbox
|
||||||
|
{...form.getInputProps("pushNotificationsEnabled", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={<Trans>Auto-mark as read</Trans>}
|
label={<Trans>Auto-mark as read</Trans>}
|
||||||
description={<Trans>Mark entries in this feed as read after this number of days. Leave empty to disable.</Trans>}
|
description={
|
||||||
|
<Trans>
|
||||||
|
Mark entries in this feed as read after this number of days. Leave empty to disable.
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
suffix={` ${_(msg`days`)}`}
|
suffix={` ${_(msg`days`)}`}
|
||||||
{...form.getInputProps("autoMarkAsReadAfterDays")}
|
{...form.getInputProps("autoMarkAsReadAfterDays")}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -159,21 +169,25 @@ export function FeedDetailsPage() {
|
|||||||
label={<Trans>Filtering expression</Trans>}
|
label={<Trans>Filtering expression</Trans>}
|
||||||
description={
|
description={
|
||||||
<Trans>
|
<Trans>
|
||||||
Build a filter expression to indicate what you want to read. Entries that don't match will be marked as read
|
Build a filter expression to indicate what you want to read. Entries that don't match
|
||||||
automatically.
|
will be marked as read automatically.
|
||||||
</Trans>
|
</Trans>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{feed.filterLegacy && (
|
{feed.filterLegacy && (
|
||||||
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
|
<MantineAlert color="yellow" icon={<TbAlertTriangle />}>
|
||||||
<Trans>
|
<Trans>
|
||||||
This feed has a legacy filter that cannot be edited and is not applied. Please recreate the filter using
|
This feed has a legacy filter that cannot be edited and is not applied. Please
|
||||||
the new expression editor. The legacy filter expression was: <Code>{feed.filterLegacy}</Code>
|
recreate the filter using the new expression editor. The legacy filter expression
|
||||||
|
was: <Code>{feed.filterLegacy}</Code>
|
||||||
</Trans>
|
</Trans>
|
||||||
</MantineAlert>
|
</MantineAlert>
|
||||||
)}
|
)}
|
||||||
<Box mt="xs">
|
<Box mt="xs">
|
||||||
<FilteringExpressionEditor initialValue={feed.filter} onChange={value => form.setFieldValue("filter", value)} />
|
<FilteringExpressionEditor
|
||||||
|
initialValue={feed.filter}
|
||||||
|
onChange={value => form.setFieldValue("filter", value)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Trans } from "@lingui/react/macro"
|
|||||||
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
|
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
|
||||||
import { useViewportSize } from "@mantine/hooks"
|
import { useViewportSize } from "@mantine/hooks"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { TbEdit } from "react-icons/tb"
|
import { TbSettings } from "react-icons/tb"
|
||||||
import { useLocation, useParams } from "react-router-dom"
|
import { useLocation, useParams } from "react-router-dom"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
import type { EntrySourceType } from "@/app/entries/slice"
|
import type { EntrySourceType } from "@/app/entries/slice"
|
||||||
@@ -20,7 +20,8 @@ function NoSubscriptionHelp() {
|
|||||||
<Box>
|
<Box>
|
||||||
<Center>
|
<Center>
|
||||||
<Trans>
|
<Trans>
|
||||||
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
|
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of
|
||||||
|
the page?
|
||||||
</Trans>
|
</Trans>
|
||||||
</Center>
|
</Center>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -51,7 +52,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
|||||||
const viewport = useViewportSize()
|
const viewport = useViewportSize()
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
const noSubscriptions = useAppSelector(
|
const noSubscriptions = useAppSelector(
|
||||||
state => state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
state =>
|
||||||
|
state.tree.rootCategory && flattenCategoryTree(state.tree.rootCategory).every(c => c.feeds.length === 0)
|
||||||
)
|
)
|
||||||
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
|
||||||
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
|
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
|
||||||
@@ -63,7 +65,8 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
|||||||
if (!root) return { unreadCount: 0, hasNewEntries: false }
|
if (!root) return { unreadCount: 0, hasNewEntries: false }
|
||||||
|
|
||||||
if (props.sourceType === "category") {
|
if (props.sourceType === "category") {
|
||||||
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
const category =
|
||||||
|
id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
|
||||||
return {
|
return {
|
||||||
unreadCount: categoryUnreadCount(category),
|
unreadCount: categoryUnreadCount(category),
|
||||||
hasNewEntries: categoryHasNewEntries(category),
|
hasNewEntries: categoryHasNewEntries(category),
|
||||||
@@ -138,7 +141,7 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
|
|||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
|
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
|
||||||
<TbEdit size={18} />
|
<TbSettings size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
|
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
|
|||||||
)}
|
)}
|
||||||
</OnMobile>
|
</OnMobile>
|
||||||
<OnDesktop>
|
<OnDesktop>
|
||||||
<Group p="md">
|
<Group p="md" wrap="nowrap">
|
||||||
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
|
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<LogoAndTitle />
|
<LogoAndTitle />
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function InitialSetupPage() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Box mb="md">
|
<Box mb="md">
|
||||||
<Trans>
|
<Trans>
|
||||||
Welcome! This appears to be the first time you're running CommaFeed. Please create an administrator account to get
|
Welcome! This appears to be the first time you're running CommaFeed. Please create an
|
||||||
started.
|
administrator account to get started.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Box>
|
</Box>
|
||||||
{setup.error && (
|
{setup.error && (
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -49,7 +61,11 @@ export function LoginPage() {
|
|||||||
placeholder={_(msg`User Name or E-mail`)}
|
placeholder={_(msg`User Name or E-mail`)}
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
description={
|
description={
|
||||||
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
|
serverInfos?.demoAccountEnabled ? (
|
||||||
|
<Trans>Try out CommaFeed with the demo account: demo/demo</Trans>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="md"
|
size="md"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { msg } from "@lingui/core/macro"
|
import { msg } from "@lingui/core/macro"
|
||||||
import { useLingui } from "@lingui/react"
|
import { useLingui } from "@lingui/react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
import {
|
||||||
|
Anchor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core"
|
||||||
import { useForm } from "@mantine/form"
|
import { useForm } from "@mantine/form"
|
||||||
import { useAsyncCallback } from "react-async-hook"
|
import { useAsyncCallback } from "react-async-hook"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -71,7 +83,13 @@ export function RegistrationPage() {
|
|||||||
|
|
||||||
<form onSubmit={form.onSubmit(register.execute)}>
|
<form onSubmit={form.onSubmit(register.execute)}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
|
<TextInput
|
||||||
|
label="User Name"
|
||||||
|
placeholder="User Name"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
size="md"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
type="email"
|
type="email"
|
||||||
label={<Trans>E-mail address</Trans>}
|
label={<Trans>E-mail address</Trans>}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import "@testing-library/jest-dom"
|
import "@testing-library/jest-dom/vitest"
|
||||||
import { vi } from "vitest"
|
import { vi } from "vitest"
|
||||||
import { Constants } from "@/app/constants"
|
import { Constants } from "@/app/constants"
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ import checker from "vite-plugin-checker"
|
|||||||
export default defineConfig(() => ({
|
export default defineConfig(() => ({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
babel({
|
lingui({ macroTransform: true }),
|
||||||
presets: [reactCompilerPreset()],
|
babel({ presets: [reactCompilerPreset()] }),
|
||||||
plugins: ["@lingui/babel-plugin-lingui-macro"],
|
|
||||||
}),
|
|
||||||
lingui(),
|
|
||||||
checker({
|
checker({
|
||||||
// temporary disabled until TypeScript 7 exposes a stable api
|
// temporary disabled until TypeScript 7 exposes a stable api
|
||||||
// typescript: true,
|
// typescript: true,
|
||||||
@@ -44,6 +41,7 @@ export default defineConfig(() => ({
|
|||||||
inconsistentCjsInterop: true,
|
inconsistentCjsInterop: true,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
isolate: false,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: "./src/setupTests.ts",
|
setupFiles: "./src/setupTests.ts",
|
||||||
@@ -51,6 +49,9 @@ export default defineConfig(() => ({
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 4000,
|
chunkSizeWarningLimit: 4000,
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
|
checks: {
|
||||||
|
pluginTimings: false,
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
codeSplitting: {
|
codeSplitting: {
|
||||||
groups: [
|
groups: [
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed</artifactId>
|
<artifactId>commafeed</artifactId>
|
||||||
<version>7.3.1</version>
|
<version>7.4.1</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>commafeed-server</artifactId>
|
<artifactId>commafeed-server</artifactId>
|
||||||
<name>CommaFeed Server</name>
|
<name>CommaFeed Server</name>
|
||||||
<packaging>quarkus</packaging>
|
<packaging>quarkus</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<quarkus.version>3.38.2</quarkus.version>
|
<quarkus.version>3.40.1.1</quarkus.version>
|
||||||
<querydsl.version>7.5</querydsl.version>
|
<querydsl.version>7.7</querydsl.version>
|
||||||
<rome.version>2.1.0</rome.version>
|
<rome.version>2.1.0</rome.version>
|
||||||
<!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
|
<!-- renovate: depName=com.google.googlejavaformat:google-java-format -->
|
||||||
<google-java-format.version>1.36.1</google-java-format.version>
|
<google-java-format.version>2.0.0</google-java-format.version>
|
||||||
|
|
||||||
<build.database>h2</build.database>
|
<build.database>h2</build.database>
|
||||||
</properties>
|
</properties>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.protobuf</groupId>
|
<groupId>com.google.protobuf</groupId>
|
||||||
<artifactId>protobuf-java</artifactId>
|
<artifactId>protobuf-java</artifactId>
|
||||||
<version>4.35.1</version>
|
<version>4.36.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>properties-maven-plugin</artifactId>
|
<artifactId>properties-maven-plugin</artifactId>
|
||||||
<version>1.3.0</version>
|
<version>1.3.1</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<goals>
|
<goals>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
<version>3.6.3</version>
|
<version>3.6.4</version>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.5.6</version>
|
<version>3.6.1</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<argLine>@{argLine}</argLine>
|
<argLine>@{argLine}</argLine>
|
||||||
<systemPropertyVariables>
|
<systemPropertyVariables>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-failsafe-plugin</artifactId>
|
<artifactId>maven-failsafe-plugin</artifactId>
|
||||||
<version>3.5.6</version>
|
<version>3.6.1</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<goals>
|
<goals>
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>io.github.git-commit-id</groupId>
|
<groupId>io.github.git-commit-id</groupId>
|
||||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||||
<version>10.0.0</version>
|
<version>10.0.1</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<goals>
|
<goals>
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.puppycrawl.tools</groupId>
|
<groupId>com.puppycrawl.tools</groupId>
|
||||||
<artifactId>checkstyle</artifactId>
|
<artifactId>checkstyle</artifactId>
|
||||||
<version>13.11.0</version>
|
<version>14.4.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<executions>
|
<executions>
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.diffplug.spotless</groupId>
|
<groupId>com.diffplug.spotless</groupId>
|
||||||
<artifactId>spotless-maven-plugin</artifactId>
|
<artifactId>spotless-maven-plugin</artifactId>
|
||||||
<version>3.10.0</version>
|
<version>3.11.0</version>
|
||||||
<?m2e ignore?>
|
<?m2e ignore?>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -290,6 +290,7 @@
|
|||||||
<googleJavaFormat>
|
<googleJavaFormat>
|
||||||
<version>${google-java-format.version}</version>
|
<version>${google-java-format.version}</version>
|
||||||
<style>AOSP</style>
|
<style>AOSP</style>
|
||||||
|
<maxLineLength>120</maxLineLength>
|
||||||
<reorderImports>true</reorderImports>
|
<reorderImports>true</reorderImports>
|
||||||
</googleJavaFormat>
|
</googleJavaFormat>
|
||||||
</java>
|
</java>
|
||||||
@@ -302,14 +303,14 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.commafeed</groupId>
|
<groupId>com.commafeed</groupId>
|
||||||
<artifactId>commafeed-client</artifactId>
|
<artifactId>commafeed-client</artifactId>
|
||||||
<version>7.3.1</version>
|
<version>7.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- compile-time processors -->
|
<!-- compile-time processors -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.46</version>
|
<version>1.18.48</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.kohsuke.metainf-services</groupId>
|
<groupId>org.kohsuke.metainf-services</groupId>
|
||||||
@@ -322,6 +323,11 @@
|
|||||||
<artifactId>quarkus-extension-processor</artifactId>
|
<artifactId>quarkus-extension-processor</artifactId>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.graalvm.sdk</groupId>
|
||||||
|
<artifactId>nativeimage</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- quarkus dependencies -->
|
<!-- quarkus dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -368,7 +374,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.dropwizard.metrics</groupId>
|
<groupId>io.dropwizard.metrics</groupId>
|
||||||
<artifactId>metrics-json</artifactId>
|
<artifactId>metrics-json</artifactId>
|
||||||
<version>4.2.39</version>
|
<version>4.2.40</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.openfeign.querydsl</groupId>
|
<groupId>io.github.openfeign.querydsl</groupId>
|
||||||
@@ -399,7 +405,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.cel</groupId>
|
<groupId>dev.cel</groupId>
|
||||||
<artifactId>cel</artifactId>
|
<artifactId>cel</artifactId>
|
||||||
<version>0.13.1</version>
|
<version>0.15.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.passay</groupId>
|
<groupId>org.passay</groupId>
|
||||||
@@ -429,12 +435,12 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.23.1</version>
|
<version>1.24.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.ibm.icu</groupId>
|
<groupId>com.ibm.icu</groupId>
|
||||||
<artifactId>icu4j</artifactId>
|
<artifactId>icu4j</artifactId>
|
||||||
<version>78.3</version>
|
<version>79.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>net.sourceforge.cssparser</groupId>
|
<groupId>net.sourceforge.cssparser</groupId>
|
||||||
@@ -449,17 +455,12 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
<artifactId>httpclient5</artifactId>
|
<artifactId>httpclient5</artifactId>
|
||||||
<version>5.6.4</version>
|
<version>5.6.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.hakky54</groupId>
|
<groupId>io.github.hakky54</groupId>
|
||||||
<artifactId>ayza-for-apache5</artifactId>
|
<artifactId>ayza-for-apache5</artifactId>
|
||||||
<version>10.0.6</version>
|
<version>10.1.0</version>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.brotli</groupId>
|
|
||||||
<artifactId>dec</artifactId>
|
|
||||||
<version>0.1.2</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.seancfoley</groupId>
|
<groupId>com.github.seancfoley</groupId>
|
||||||
@@ -476,13 +477,13 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkiverse.playwright</groupId>
|
<groupId>io.quarkiverse.playwright</groupId>
|
||||||
<artifactId>quarkus-playwright</artifactId>
|
<artifactId>quarkus-playwright</artifactId>
|
||||||
<version>2.3.8</version>
|
<version>2.3.10</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mock-server</groupId>
|
<groupId>org.mock-server</groupId>
|
||||||
<artifactId>mockserver-junit-jupiter</artifactId>
|
<artifactId>mockserver-junit-jupiter-no-dependencies</artifactId>
|
||||||
<version>7.5.0</version>
|
<version>9.2.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM ibm-semeru-runtimes:open-jdk-25.0.4.0-jre@sha256:02888a2306c2cb8af22a05b2c41be90fdc9df5f4611861ddf6ea90c874de65e6
|
FROM ibm-semeru-runtimes:open-jdk-25.0.4.15-jre@sha256:c4f8cd5ee9d607addbf957a5066144c46c853c6a81235819f6952e60237904ca
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||||
|
|
||||||
EXPOSE 8082
|
EXPOSE 8082
|
||||||
|
|
||||||
RUN mkdir -p /commafeed/data
|
RUN mkdir -p /commafeed/data
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM debian:13.6@sha256:34cd9e9fd437c0a095ec39cb2e73422c9f30821b0d0848ed74fd0d43bae4d958
|
FROM debian:13.7@sha256:2dc419c6d7fe3dad0a2f9fd469c34cc90b0f9ba642d8fde837fe3d7d34a0e7f1
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/Athou/commafeed"
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
EXPOSE 8082
|
EXPOSE 8082
|
||||||
|
|||||||
@@ -34,16 +34,14 @@ public class CommaFeedApplication {
|
|||||||
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
System.setProperty(SystemProperties.JDK_XML_MAX_GENERAL_ENTITY_SIZE_LIMIT, "0");
|
||||||
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
System.setProperty(SystemProperties.JDK_XML_TOTAL_ENTITY_SIZE_LIMIT, "0");
|
||||||
|
|
||||||
PasswordConstraintValidator.setMinimumPasswordLength(
|
PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
|
||||||
config.users().minimumPasswordLength());
|
|
||||||
|
|
||||||
if (config.imageProxyEnabled()) {
|
if (config.imageProxyEnabled()) {
|
||||||
ImageProxyUrl.generateKey();
|
ImageProxyUrl.generateKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
|
if (config.passwordRecoveryEnabled() && config.passwordRecoveryPublicBaseUrl().isEmpty()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException("password recovery is enabled but no public base url is set");
|
||||||
"password recovery is enabled but no public base url is set");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
feedRefreshEngine.start();
|
feedRefreshEngine.start();
|
||||||
|
|||||||
@@ -19,24 +19,20 @@ import java.util.Optional;
|
|||||||
/**
|
/**
|
||||||
* CommaFeed configuration
|
* CommaFeed configuration
|
||||||
*
|
*
|
||||||
* <p>Default values are for production, they can be overridden in application.properties for other
|
* <p>Default values are for production, they can be overridden in application.properties for other profiles
|
||||||
* profiles
|
|
||||||
*/
|
*/
|
||||||
@ConfigMapping(prefix = "commafeed")
|
@ConfigMapping(prefix = "commafeed")
|
||||||
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
|
@ConfigRoot(phase = ConfigPhase.RUN_TIME)
|
||||||
public interface CommaFeedConfiguration {
|
public interface CommaFeedConfiguration {
|
||||||
/**
|
/** Whether to expose a robots.txt file that disallows web crawlers and search engine indexers. */
|
||||||
* Whether to expose a robots.txt file that disallows web crawlers and search engine indexers.
|
|
||||||
*/
|
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean hideFromWebCrawlers();
|
boolean hideFromWebCrawlers();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If enabled, images in feed entries will be proxied through the server instead of accessed
|
* If enabled, images in feed entries will be proxied through the server instead of accessed directly by the
|
||||||
* directly by the browser.
|
* browser.
|
||||||
*
|
*
|
||||||
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds
|
* <p>This is useful if commafeed is accessed through a restricting proxy that blocks some feeds that are followed.
|
||||||
* that are followed.
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("false")
|
@WithDefault("false")
|
||||||
boolean imageProxyEnabled();
|
boolean imageProxyEnabled();
|
||||||
@@ -50,9 +46,8 @@ public interface CommaFeedConfiguration {
|
|||||||
boolean passwordRecoveryEnabled();
|
boolean passwordRecoveryEnabled();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base URL of the application to use in the password recovery link in the email. We can't
|
* The base URL of the application to use in the password recovery link in the email. We can't use the URL sent by
|
||||||
* use the URL sent by the browser of the user because of malicious Host header injection
|
* the browser of the user because of malicious Host header injection attacks.
|
||||||
* attacks.
|
|
||||||
*/
|
*/
|
||||||
Optional<String> passwordRecoveryPublicBaseUrl();
|
Optional<String> passwordRecoveryPublicBaseUrl();
|
||||||
|
|
||||||
@@ -87,16 +82,14 @@ public interface CommaFeedConfiguration {
|
|||||||
Websocket websocket();
|
Websocket websocket();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration to wait for the feed refresh engine and the task scheduler to stop when the
|
* Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting
|
||||||
* application is shutting down.
|
* down.
|
||||||
*/
|
*/
|
||||||
@WithDefault("2s")
|
@WithDefault("2s")
|
||||||
Duration shutdownTimeout();
|
Duration shutdownTimeout();
|
||||||
|
|
||||||
interface HttpClient {
|
interface HttpClient {
|
||||||
/**
|
/** User-Agent string that will be used by the http client, leave empty for the default one. */
|
||||||
* User-Agent string that will be used by the http client, leave empty for the default one.
|
|
||||||
*/
|
|
||||||
Optional<String> userAgent();
|
Optional<String> userAgent();
|
||||||
|
|
||||||
/** Time to wait for a connection to be established. */
|
/** Time to wait for a connection to be established. */
|
||||||
@@ -123,19 +116,15 @@ public interface CommaFeedConfiguration {
|
|||||||
@WithDefault("1m")
|
@WithDefault("1m")
|
||||||
Duration idleConnectionsEvictionInterval();
|
Duration idleConnectionsEvictionInterval();
|
||||||
|
|
||||||
/**
|
/** If a feed is larger than this, it will be discarded to prevent memory issues while parsing the feed. */
|
||||||
* If a feed is larger than this, it will be discarded to prevent memory issues while
|
|
||||||
* parsing the feed.
|
|
||||||
*/
|
|
||||||
@WithDefault("5M")
|
@WithDefault("5M")
|
||||||
MemorySize maxResponseSize();
|
MemorySize maxResponseSize();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks,
|
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could
|
||||||
* which could potentially expose internal resources.
|
* potentially expose internal resources.
|
||||||
*
|
*
|
||||||
* <p>You may want to enable this if you host a public instance of CommaFeed with
|
* <p>You may want to enable this if you host a public instance of CommaFeed with registrations open.
|
||||||
* registrations open.
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean blockLocalAddresses();
|
boolean blockLocalAddresses();
|
||||||
@@ -147,9 +136,8 @@ public interface CommaFeedConfiguration {
|
|||||||
|
|
||||||
interface HttpClientCache {
|
interface HttpClientCache {
|
||||||
/**
|
/**
|
||||||
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts
|
* Whether to enable the cache. This cache is used to avoid spamming feeds in short bursts (e.g. when
|
||||||
* (e.g. when subscribing to a feed for the first time or when clicking "fetch all my feeds
|
* subscribing to a feed for the first time or when clicking "fetch all my feeds now").
|
||||||
* now").
|
|
||||||
*/
|
*/
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean enabled();
|
boolean enabled();
|
||||||
@@ -169,8 +157,7 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration interval();
|
Duration interval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an
|
* Maximum amount of time CommaFeed will wait before refreshing a feed. This is used as an upper bound when:
|
||||||
* upper bound when:
|
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>an error occurs while refreshing a feed and we're backing off exponentially
|
* <li>an error occurs while refreshing a feed and we're backing off exponentially
|
||||||
@@ -182,10 +169,10 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration maxInterval();
|
Duration maxInterval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average
|
* If enabled, CommaFeed will calculate the next refresh time based on the feed's average time between entries
|
||||||
* time between entries and the time since the last entry was published. The interval will
|
* and the time since the last entry was published. The interval will be sometimes between the default refresh
|
||||||
* be sometimes between the default refresh interval (`commafeed.feed-refresh.interval`) and
|
* interval (`commafeed.feed-refresh.interval`) and the maximum refresh interval
|
||||||
* the maximum refresh interval (`commafeed.feed-refresh.max-interval`).
|
* (`commafeed.feed-refresh.max-interval`).
|
||||||
*
|
*
|
||||||
* <p>See {@link FeedRefreshIntervalCalculator} for details.
|
* <p>See {@link FeedRefreshIntervalCalculator} for details.
|
||||||
*/
|
*/
|
||||||
@@ -207,8 +194,8 @@ public interface CommaFeedConfiguration {
|
|||||||
int databaseThreads();
|
int databaseThreads();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which a user is considered inactive. Feeds for inactive users are not
|
* Duration after which a user is considered inactive. Feeds for inactive users are not refreshed until they log
|
||||||
* refreshed until they log in again.
|
* in again.
|
||||||
*
|
*
|
||||||
* <p>0 to disable.
|
* <p>0 to disable.
|
||||||
*/
|
*/
|
||||||
@@ -216,15 +203,15 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration userInactivityPeriod();
|
Duration userInactivityPeriod();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which the evaluation of a filtering expresion to mark an entry as read is
|
* Duration after which the evaluation of a filtering expresion to mark an entry as read is considered to have
|
||||||
* considered to have timed out.
|
* timed out.
|
||||||
*/
|
*/
|
||||||
@WithDefault("500ms")
|
@WithDefault("500ms")
|
||||||
Duration filteringExpressionEvaluationTimeout();
|
Duration filteringExpressionEvaluationTimeout();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration after which the "Fetch all my feeds now" action is available again after use to
|
* Duration after which the "Fetch all my feeds now" action is available again after use to avoid spamming
|
||||||
* avoid spamming feeds.
|
* feeds.
|
||||||
*/
|
*/
|
||||||
@WithDefault("0")
|
@WithDefault("0")
|
||||||
Duration forceRefreshCooldownDuration();
|
Duration forceRefreshCooldownDuration();
|
||||||
@@ -240,10 +227,7 @@ public interface CommaFeedConfiguration {
|
|||||||
@WithDefault("5")
|
@WithDefault("5")
|
||||||
int threads();
|
int threads();
|
||||||
|
|
||||||
/**
|
/** Maximum amount of notifications that can be queued before new notifications are discarded. */
|
||||||
* Maximum amount of notifications that can be queued before new notifications are
|
|
||||||
* discarded.
|
|
||||||
*/
|
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@WithDefault("100")
|
@WithDefault("100")
|
||||||
int queueCapacity();
|
int queueCapacity();
|
||||||
@@ -256,8 +240,8 @@ public interface CommaFeedConfiguration {
|
|||||||
int retriesBeforeBackoff();
|
int retriesBeforeBackoff();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Duration to wait before retrying after an error. Will be multiplied by the number of
|
* Duration to wait before retrying after an error. Will be multiplied by the number of errors since the last
|
||||||
* errors since the last successful fetch.
|
* successful fetch.
|
||||||
*/
|
*/
|
||||||
@WithDefault("1h")
|
@WithDefault("1h")
|
||||||
Duration backoffInterval();
|
Duration backoffInterval();
|
||||||
@@ -286,8 +270,7 @@ public interface CommaFeedConfiguration {
|
|||||||
Duration entriesMaxAge();
|
Duration entriesMaxAge();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will
|
* Maximum age of feed entry statuses (read/unread) in the database. Older statuses will be deleted.
|
||||||
* be deleted.
|
|
||||||
*
|
*
|
||||||
* <p>0 to disable.
|
* <p>0 to disable.
|
||||||
*/
|
*/
|
||||||
@@ -320,9 +303,7 @@ public interface CommaFeedConfiguration {
|
|||||||
boolean keepStarredEntries();
|
boolean keepStarredEntries();
|
||||||
|
|
||||||
default Instant statusesInstantThreshold() {
|
default Instant statusesInstantThreshold() {
|
||||||
return statusesMaxAge().toMillis() > 0
|
return statusesMaxAge().toMillis() > 0 ? Instant.now().minus(statusesMaxAge()) : null;
|
||||||
? Instant.now().minus(statusesMaxAge())
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,23 +327,17 @@ public interface CommaFeedConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Websocket {
|
interface Websocket {
|
||||||
/**
|
/** Enable websocket connection so the server can notify web clients that there are new entries for feeds. */
|
||||||
* Enable websocket connection so the server can notify web clients that there are new
|
|
||||||
* entries for feeds.
|
|
||||||
*/
|
|
||||||
@WithDefault("true")
|
@WithDefault("true")
|
||||||
boolean enabled();
|
boolean enabled();
|
||||||
|
|
||||||
/**
|
/** Interval at which the client will send a ping message on the websocket to keep the connection alive. */
|
||||||
* Interval at which the client will send a ping message on the websocket to keep the
|
|
||||||
* connection alive.
|
|
||||||
*/
|
|
||||||
@WithDefault("15m")
|
@WithDefault("15m")
|
||||||
Duration pingInterval();
|
Duration pingInterval();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the websocket connection is disabled or the connection is lost, the client will reload
|
* If the websocket connection is disabled or the connection is lost, the client will reload the feed tree at
|
||||||
* the feed tree at this interval.
|
* this interval.
|
||||||
*/
|
*/
|
||||||
@WithDefault("30s")
|
@WithDefault("30s")
|
||||||
Duration treeReloadInterval();
|
Duration treeReloadInterval();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.commafeed;
|
package com.commafeed;
|
||||||
|
|
||||||
|
import com.commafeed.frontend.exception.CommaFeedApplicationException;
|
||||||
|
import com.commafeed.frontend.exception.CommaFeedExceptionType;
|
||||||
import com.commafeed.security.CookieService;
|
import com.commafeed.security.CookieService;
|
||||||
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
@@ -8,7 +10,10 @@ import io.quarkus.security.UnauthorizedException;
|
|||||||
|
|
||||||
import jakarta.annotation.Priority;
|
import jakarta.annotation.Priority;
|
||||||
import jakarta.validation.ValidationException;
|
import jakarta.validation.ValidationException;
|
||||||
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.NewCookie;
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import jakarta.ws.rs.ext.Provider;
|
import jakarta.ws.rs.ext.Provider;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -26,17 +31,37 @@ public class ExceptionMappers {
|
|||||||
private final CookieService cookieService;
|
private final CookieService cookieService;
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
|
|
||||||
|
@ServerExceptionMapper(CommaFeedApplicationException.class)
|
||||||
|
public RestResponse<CommaFeedApplicationError> applicationError(CommaFeedApplicationException e) {
|
||||||
|
ResponseBuilder<CommaFeedApplicationError> response =
|
||||||
|
ResponseBuilder.create(e.type().status(), new CommaFeedApplicationError(e.type(), e.getMessage()));
|
||||||
|
if (e.type().status() == Status.UNAUTHORIZED) {
|
||||||
|
response.cookie(cookieService.buildLogoutCookie());
|
||||||
|
}
|
||||||
|
return response.build();
|
||||||
|
}
|
||||||
|
|
||||||
@ServerExceptionMapper(UnauthorizedException.class)
|
@ServerExceptionMapper(UnauthorizedException.class)
|
||||||
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
public RestResponse<UnauthorizedResponse> unauthorized(UnauthorizedException e) {
|
||||||
return RestResponse.status(
|
return RestResponse.status(
|
||||||
Status.UNAUTHORIZED,
|
Status.UNAUTHORIZED, new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
|
||||||
new UnauthorizedResponse(e.getMessage(), config.users().allowRegistrations()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ServerExceptionMapper(AuthenticationFailedException.class)
|
@ServerExceptionMapper(AuthenticationFailedException.class)
|
||||||
public RestResponse<AuthenticationFailed> authenticationFailed(
|
public RestResponse<AuthenticationFailed> authenticationFailed(
|
||||||
AuthenticationFailedException e) {
|
AuthenticationFailedException e, HttpHeaders headers, UriInfo uriInfo) {
|
||||||
NewCookie logoutCookie = cookieService.buildLogoutCookie();
|
NewCookie logoutCookie = cookieService.buildLogoutCookie();
|
||||||
|
|
||||||
|
// a browser navigating with an invalidated session would otherwise display the error
|
||||||
|
// instead of the page, retry the request now that the cookie is removed
|
||||||
|
if (headers.getAcceptableMediaTypes().stream()
|
||||||
|
.anyMatch(m -> MediaType.TEXT_HTML_TYPE.isCompatible(m) && !m.isWildcardType())) {
|
||||||
|
return ResponseBuilder.<AuthenticationFailed>create(Status.TEMPORARY_REDIRECT)
|
||||||
|
.location(uriInfo.getRequestUri())
|
||||||
|
.cookie(logoutCookie)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
|
return ResponseBuilder.create(Status.UNAUTHORIZED, new AuthenticationFailed(e.getMessage()))
|
||||||
.cookie(logoutCookie)
|
.cookie(logoutCookie)
|
||||||
.build();
|
.build();
|
||||||
@@ -55,4 +80,7 @@ public class ExceptionMappers {
|
|||||||
|
|
||||||
@RegisterForReflection
|
@RegisterForReflection
|
||||||
public record ValidationFailed(String message) {}
|
public record ValidationFailed(String message) {}
|
||||||
|
|
||||||
|
@RegisterForReflection
|
||||||
|
public record CommaFeedApplicationError(CommaFeedExceptionType type, String message) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.codahale.metrics.Histogram;
|
|||||||
import com.codahale.metrics.Meter;
|
import com.codahale.metrics.Meter;
|
||||||
import com.codahale.metrics.MetricRegistry;
|
import com.codahale.metrics.MetricRegistry;
|
||||||
import com.codahale.metrics.Timer;
|
import com.codahale.metrics.Timer;
|
||||||
|
import com.oracle.svm.core.annotate.Substitute;
|
||||||
|
import com.oracle.svm.core.annotate.TargetClass;
|
||||||
|
|
||||||
import io.quarkus.runtime.annotations.RegisterForReflection;
|
import io.quarkus.runtime.annotations.RegisterForReflection;
|
||||||
|
|
||||||
@@ -346,4 +348,22 @@ import io.quarkus.runtime.annotations.RegisterForReflection;
|
|||||||
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class,
|
com.rometools.opml.feed.synd.impl.ConverterForOPML10.class,
|
||||||
com.rometools.opml.feed.synd.impl.ConverterForOPML20.class,
|
com.rometools.opml.feed.synd.impl.ConverterForOPML20.class,
|
||||||
})
|
})
|
||||||
public class NativeImageClasses {}
|
public class NativeImageClasses {
|
||||||
|
|
||||||
|
// disable zstd and brotli runtime detection because it causes native image failure
|
||||||
|
@TargetClass(className = "org.apache.hc.client5.http.impl.ZstdRuntime")
|
||||||
|
static final class ZstdRuntime {
|
||||||
|
@Substitute
|
||||||
|
public static boolean available() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TargetClass(className = "org.apache.hc.client5.http.impl.Brotli4jRuntime")
|
||||||
|
static final class Brotli4jRuntime {
|
||||||
|
@Substitute
|
||||||
|
public static boolean available() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,27 +19,27 @@ import nl.altindag.ssl.apache5.util.Apache5SslUtils;
|
|||||||
|
|
||||||
import org.apache.hc.client5.http.DnsResolver;
|
import org.apache.hc.client5.http.DnsResolver;
|
||||||
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
import org.apache.hc.client5.http.SystemDefaultDnsResolver;
|
||||||
|
import org.apache.hc.client5.http.async.methods.InflatingAsyncDataConsumer;
|
||||||
|
import org.apache.hc.client5.http.async.methods.InflatingGzipDataConsumer;
|
||||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||||
import org.apache.hc.client5.http.config.TlsConfig;
|
import org.apache.hc.client5.http.config.TlsConfig;
|
||||||
import org.apache.hc.client5.http.entity.DeflateInputStream;
|
|
||||||
import org.apache.hc.client5.http.entity.InputStreamFactory;
|
|
||||||
import org.apache.hc.client5.http.entity.compress.ContentCoding;
|
import org.apache.hc.client5.http.entity.compress.ContentCoding;
|
||||||
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
|
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
|
||||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||||
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
|
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
|
||||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
|
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
|
||||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
|
||||||
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
|
|
||||||
import org.apache.hc.client5.http.protocol.RedirectStrategy;
|
import org.apache.hc.client5.http.protocol.RedirectStrategy;
|
||||||
import org.apache.hc.core5.http.Header;
|
import org.apache.hc.core5.http.Header;
|
||||||
import org.apache.hc.core5.http.HttpException;
|
import org.apache.hc.core5.http.HttpException;
|
||||||
import org.apache.hc.core5.http.HttpRequest;
|
import org.apache.hc.core5.http.HttpRequest;
|
||||||
import org.apache.hc.core5.http.HttpResponse;
|
import org.apache.hc.core5.http.HttpResponse;
|
||||||
import org.apache.hc.core5.http.message.BasicHeader;
|
import org.apache.hc.core5.http.message.BasicHeader;
|
||||||
|
import org.apache.hc.core5.http.nio.AsyncDataConsumer;
|
||||||
import org.apache.hc.core5.http.protocol.HttpContext;
|
import org.apache.hc.core5.http.protocol.HttpContext;
|
||||||
|
import org.apache.hc.core5.http2.HttpVersionPolicy;
|
||||||
import org.apache.hc.core5.util.TimeValue;
|
import org.apache.hc.core5.util.TimeValue;
|
||||||
import org.apache.hc.core5.util.Timeout;
|
import org.apache.hc.core5.util.Timeout;
|
||||||
import org.brotli.dec.BrotliInputStream;
|
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -48,7 +48,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.SequencedMap;
|
import java.util.SequencedMap;
|
||||||
import java.util.zip.GZIPInputStream;
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -56,81 +56,77 @@ public class HttpClientFactory {
|
|||||||
|
|
||||||
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
|
private static final DnsResolver DNS_RESOLVER = SystemDefaultDnsResolver.INSTANCE;
|
||||||
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
|
private static final IPAddress CGNAT_RANGE = new IPAddressString("100.64.0.0/10").getAddress();
|
||||||
|
private static final IPAddress NAT64_LOCAL_USE_RANGE = new IPAddressString("64:ff9b:1::/48").getAddress();
|
||||||
|
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
private final CommaFeedVersion version;
|
private final CommaFeedVersion version;
|
||||||
|
|
||||||
public CloseableHttpClient newClient(int poolSize) {
|
public CloseableHttpAsyncClient newClient(int poolSize) {
|
||||||
PoolingHttpClientConnectionManager connectionManager =
|
PoolingAsyncClientConnectionManager connectionManager = newConnectionManager(config, poolSize);
|
||||||
newConnectionManager(config, poolSize);
|
|
||||||
String userAgent =
|
String userAgent =
|
||||||
config.httpClient()
|
config.httpClient()
|
||||||
.userAgent()
|
.userAgent()
|
||||||
.orElseGet(
|
.orElseGet(() ->
|
||||||
() ->
|
String.format(
|
||||||
String.format(
|
"CommaFeed/%s (https://github.com/Athou/commafeed)", version.getVersion()));
|
||||||
"CommaFeed/%s (https://github.com/Athou/commafeed)",
|
|
||||||
version.getVersion()));
|
|
||||||
return newClient(config, connectionManager, userAgent);
|
return newClient(config, connectionManager, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private CloseableHttpClient newClient(
|
private CloseableHttpAsyncClient newClient(
|
||||||
CommaFeedConfiguration config,
|
CommaFeedConfiguration config, PoolingAsyncClientConnectionManager connectionManager, String userAgent) {
|
||||||
HttpClientConnectionManager connectionManager,
|
|
||||||
String userAgent) {
|
|
||||||
List<Header> headers = new ArrayList<>();
|
List<Header> headers = new ArrayList<>();
|
||||||
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
headers.add(new BasicHeader(HttpHeaders.ACCEPT_LANGUAGE, "en"));
|
||||||
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
headers.add(new BasicHeader(HttpHeaders.PRAGMA, "No-cache"));
|
||||||
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
|
headers.add(new BasicHeader(HttpHeaders.CACHE_CONTROL, "no-cache"));
|
||||||
|
headers.add(new BasicHeader(HttpHeaders.ACCEPT, "*/*"));
|
||||||
|
headers.add(new BasicHeader(HttpHeaders.SEC_FETCH_MODE, "navigate"));
|
||||||
|
|
||||||
SequencedMap<String, InputStreamFactory> contentDecoderMap = new LinkedHashMap<>();
|
SequencedMap<String, UnaryOperator<AsyncDataConsumer>> contentDecoderMap = new LinkedHashMap<>();
|
||||||
contentDecoderMap.put(ContentCoding.GZIP.token(), GZIPInputStream::new);
|
contentDecoderMap.put(ContentCoding.DEFLATE.token(), d -> new InflatingAsyncDataConsumer(d, null));
|
||||||
contentDecoderMap.put(ContentCoding.DEFLATE.token(), DeflateInputStream::new);
|
contentDecoderMap.put(ContentCoding.GZIP.token(), InflatingGzipDataConsumer::new);
|
||||||
contentDecoderMap.put(ContentCoding.BROTLI.token(), BrotliInputStream::new);
|
|
||||||
|
|
||||||
RedirectStrategy redirectStrategy =
|
RedirectStrategy redirectStrategy =
|
||||||
config.httpClient().blockLocalAddresses()
|
config.httpClient().blockLocalAddresses()
|
||||||
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
|
? new BlockLocalAddressesRedirectStrategy(DNS_RESOLVER)
|
||||||
: new DefaultRedirectStrategy();
|
: new DefaultRedirectStrategy();
|
||||||
|
|
||||||
return HttpClientBuilder.create()
|
CloseableHttpAsyncClient client =
|
||||||
.disableConnectionState()
|
HttpAsyncClients.custom()
|
||||||
.useSystemProperties()
|
.disableConnectionState()
|
||||||
.disableAutomaticRetries()
|
.useSystemProperties()
|
||||||
.disableCookieManagement()
|
.disableAutomaticRetries()
|
||||||
.setUserAgent(userAgent)
|
.disableCookieManagement()
|
||||||
.setDefaultHeaders(headers)
|
.setUserAgent(userAgent)
|
||||||
.setConnectionManager(connectionManager)
|
.setDefaultHeaders(headers)
|
||||||
.evictExpiredConnections()
|
.setConnectionManager(connectionManager)
|
||||||
.evictIdleConnections(
|
.evictExpiredConnections()
|
||||||
TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
.evictIdleConnections(TimeValue.of(config.httpClient().idleConnectionsEvictionInterval()))
|
||||||
.setContentDecoderRegistry(new LinkedHashMap<>(contentDecoderMap))
|
.setContentDecoderMap(new LinkedHashMap<>(contentDecoderMap))
|
||||||
.setRedirectStrategy(redirectStrategy)
|
.setRedirectStrategy(redirectStrategy)
|
||||||
.build();
|
.build();
|
||||||
|
client.start();
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
private PoolingHttpClientConnectionManager newConnectionManager(
|
private PoolingAsyncClientConnectionManager newConnectionManager(CommaFeedConfiguration config, int poolSize) {
|
||||||
CommaFeedConfiguration config, int poolSize) {
|
SSLFactory sslFactory = SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
||||||
SSLFactory sslFactory =
|
|
||||||
SSLFactory.builder().withUnsafeTrustMaterial().withUnsafeHostnameVerifier().build();
|
|
||||||
DnsResolver dnsResolver =
|
DnsResolver dnsResolver =
|
||||||
config.httpClient().blockLocalAddresses()
|
config.httpClient().blockLocalAddresses()
|
||||||
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
? new BlockLocalAddressesDnsResolver(DNS_RESOLVER)
|
||||||
: DNS_RESOLVER;
|
: DNS_RESOLVER;
|
||||||
|
|
||||||
return PoolingHttpClientConnectionManagerBuilder.create()
|
return PoolingAsyncClientConnectionManagerBuilder.create()
|
||||||
.setTlsSocketStrategy(Apache5SslUtils.toTlsSocketStrategy(sslFactory))
|
.setTlsStrategy(Apache5SslUtils.toTlsStrategy(sslFactory))
|
||||||
.setDefaultConnectionConfig(
|
.setDefaultConnectionConfig(
|
||||||
ConnectionConfig.custom()
|
ConnectionConfig.custom()
|
||||||
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
.setConnectTimeout(Timeout.of(config.httpClient().connectTimeout()))
|
||||||
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
|
.setSocketTimeout(Timeout.of(config.httpClient().socketTimeout()))
|
||||||
.setTimeToLive(
|
.setTimeToLive(Timeout.of(config.httpClient().connectionTimeToLive()))
|
||||||
Timeout.of(config.httpClient().connectionTimeToLive()))
|
|
||||||
.build())
|
.build())
|
||||||
.setDefaultTlsConfig(
|
.setDefaultTlsConfig(
|
||||||
TlsConfig.custom()
|
TlsConfig.custom()
|
||||||
.setHandshakeTimeout(
|
.setVersionPolicy(HttpVersionPolicy.NEGOTIATE)
|
||||||
Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
.setHandshakeTimeout(Timeout.of(config.httpClient().sslHandshakeTimeout()))
|
||||||
.build())
|
.build())
|
||||||
.setMaxConnPerRoute(poolSize)
|
.setMaxConnPerRoute(poolSize)
|
||||||
.setMaxConnTotal(poolSize)
|
.setMaxConnTotal(poolSize)
|
||||||
@@ -143,7 +139,11 @@ public class HttpClientFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isLocalAddress(IPAddress ip) {
|
private static boolean isLocalAddress(IPAddress ip) {
|
||||||
if (ip.isLocal() || ip.isLoopback() || ip.isMulticast() || CGNAT_RANGE.contains(ip)) {
|
if (ip.isLocal()
|
||||||
|
|| ip.isLoopback()
|
||||||
|
|| ip.isMulticast()
|
||||||
|
|| CGNAT_RANGE.contains(ip)
|
||||||
|
|| NAT64_LOCAL_USE_RANGE.contains(ip)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +179,7 @@ public class HttpClientFactory {
|
|||||||
InetAddress[] addresses = delegate.resolve(host);
|
InetAddress[] addresses = delegate.resolve(host);
|
||||||
for (InetAddress addr : addresses) {
|
for (InetAddress addr : addresses) {
|
||||||
if (isLocalAddress(addr)) {
|
if (isLocalAddress(addr)) {
|
||||||
throw new UnknownHostException(
|
throw new UnknownHostException("Access to local address blocked: " + addr.getHostAddress());
|
||||||
"Access to local address blocked: " + addr.getHostAddress());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return addresses;
|
return addresses;
|
||||||
@@ -216,8 +215,7 @@ public class HttpClientFactory {
|
|||||||
|
|
||||||
for (InetAddress addr : addresses) {
|
for (InetAddress addr : addresses) {
|
||||||
if (isLocalAddress(addr)) {
|
if (isLocalAddress(addr)) {
|
||||||
throw new HttpException(
|
throw new HttpException("Access to local address blocked: " + addr.getHostAddress());
|
||||||
"Access to local address blocked: " + addr.getHostAddress());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.commafeed.CommaFeedConfiguration.HttpClientCache;
|
|||||||
import com.google.common.cache.Cache;
|
import com.google.common.cache.Cache;
|
||||||
import com.google.common.cache.CacheBuilder;
|
import com.google.common.cache.CacheBuilder;
|
||||||
import com.google.common.collect.Iterables;
|
import com.google.common.collect.Iterables;
|
||||||
import com.google.common.io.ByteStreams;
|
|
||||||
import com.google.common.net.HttpHeaders;
|
import com.google.common.net.HttpHeaders;
|
||||||
|
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
@@ -17,39 +16,53 @@ import lombok.EqualsAndHashCode;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Lombok;
|
import lombok.Lombok;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.experimental.Delegate;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.apache.commons.lang3.ArrayUtils;
|
import org.apache.commons.lang3.ArrayUtils;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
|
||||||
|
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
|
||||||
|
import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
|
||||||
import org.apache.hc.client5.http.config.RequestConfig;
|
import org.apache.hc.client5.http.config.RequestConfig;
|
||||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
|
||||||
import org.apache.hc.client5.http.protocol.HttpClientContext;
|
import org.apache.hc.client5.http.protocol.HttpClientContext;
|
||||||
import org.apache.hc.client5.http.protocol.RedirectLocations;
|
import org.apache.hc.client5.http.protocol.RedirectLocations;
|
||||||
import org.apache.hc.client5.http.utils.DateUtils;
|
import org.apache.hc.client5.http.utils.DateUtils;
|
||||||
import org.apache.hc.core5.http.ClassicHttpRequest;
|
import org.apache.hc.core5.concurrent.FutureCallback;
|
||||||
import org.apache.hc.core5.http.HttpEntity;
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.EntityDetails;
|
||||||
|
import org.apache.hc.core5.http.HttpException;
|
||||||
import org.apache.hc.core5.http.HttpStatus;
|
import org.apache.hc.core5.http.HttpStatus;
|
||||||
|
import org.apache.hc.core5.http.Message;
|
||||||
import org.apache.hc.core5.http.NameValuePair;
|
import org.apache.hc.core5.http.NameValuePair;
|
||||||
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
|
||||||
import org.apache.hc.core5.util.Timeout;
|
import org.apache.hc.core5.http.nio.AsyncRequestProducer;
|
||||||
|
import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
|
||||||
|
import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
|
||||||
|
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
|
||||||
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
|
import org.jboss.resteasy.reactive.common.headers.CacheControlDelegate;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.InstantSource;
|
import java.time.InstantSource;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/** Smart HTTP getter: handles gzip, ssl, last modified and etag headers */
|
/** Smart HTTP getter: handles gzip, ssl, last modified, and etag headers */
|
||||||
@Singleton
|
@Singleton
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class HttpGetter {
|
public class HttpGetter {
|
||||||
private final CommaFeedConfiguration config;
|
private final CommaFeedConfiguration config;
|
||||||
private final InstantSource instantSource;
|
private final InstantSource instantSource;
|
||||||
private final CloseableHttpClient client;
|
private final CloseableHttpAsyncClient client;
|
||||||
private final Cache<HttpRequest, HttpResponse> cache;
|
private final Cache<HttpRequest, HttpResponse> cache;
|
||||||
|
|
||||||
public HttpGetter(
|
public HttpGetter(
|
||||||
@@ -62,32 +75,22 @@ public class HttpGetter {
|
|||||||
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
|
this.client = httpClientFactory.newClient(config.feedRefresh().httpThreads());
|
||||||
this.cache = newCache(config);
|
this.cache = newCache(config);
|
||||||
|
|
||||||
metrics.registerGauge(
|
metrics.registerGauge(MetricRegistry.name(getClass(), "cache", "size"), () -> cache == null ? 0 : cache.size());
|
||||||
MetricRegistry.name(getClass(), "cache", "size"),
|
|
||||||
() -> cache == null ? 0 : cache.size());
|
|
||||||
metrics.registerGauge(
|
metrics.registerGauge(
|
||||||
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
|
MetricRegistry.name(getClass(), "cache", "memoryUsage"),
|
||||||
() ->
|
() ->
|
||||||
cache == null
|
cache == null
|
||||||
? 0
|
? 0
|
||||||
: cache.asMap().values().stream()
|
: cache.asMap().values().stream().mapToInt(e -> ArrayUtils.getLength(e.content)).sum());
|
||||||
.mapToInt(e -> ArrayUtils.getLength(e.content))
|
|
||||||
.sum());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpResult get(String url)
|
public HttpResult get(String url)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
return get(HttpRequest.builder(url).build());
|
return get(HttpRequest.builder(url).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
public HttpResult get(HttpRequest request)
|
public HttpResult get(HttpRequest request)
|
||||||
throws IOException,
|
throws IOException, NotModifiedException, TooManyRequestsException, SchemeNotAllowedException {
|
||||||
NotModifiedException,
|
|
||||||
TooManyRequestsException,
|
|
||||||
SchemeNotAllowedException {
|
|
||||||
URI uri = URI.create(request.getUrl());
|
URI uri = URI.create(request.getUrl());
|
||||||
ensureHttpScheme(uri.getScheme());
|
ensureHttpScheme(uri.getScheme());
|
||||||
|
|
||||||
@@ -150,70 +153,95 @@ public class HttpGetter {
|
|||||||
HttpClientContext context = HttpClientContext.create();
|
HttpClientContext context = HttpClientContext.create();
|
||||||
context.setRequestConfig(
|
context.setRequestConfig(
|
||||||
RequestConfig.custom()
|
RequestConfig.custom()
|
||||||
.setResponseTimeout(Timeout.of(config.httpClient().responseTimeout()))
|
|
||||||
// causes issues with some feeds
|
// causes issues with some feeds
|
||||||
// see https://github.com/Athou/commafeed/issues/1572
|
// see https://github.com/Athou/commafeed/issues/1572
|
||||||
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
|
// and https://issues.apache.org/jira/browse/HTTPCLIENT-2344
|
||||||
.setProtocolUpgradeEnabled(false)
|
.setProtocolUpgradeEnabled(false)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
return client.execute(
|
long maxResponseSize = config.httpClient().maxResponseSize().asLongValue();
|
||||||
request.toClassicHttpRequest(),
|
AsyncRequestProducer req = SimpleRequestProducer.create(request.toSimpleHttpRequest());
|
||||||
context,
|
AsyncResponseConsumer<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> resp =
|
||||||
resp -> {
|
new BasicResponseConsumer<>(
|
||||||
byte[] content =
|
new LimitedContentLengthEntityConsumer(
|
||||||
resp.getEntity() == null
|
new LimitedBytesEntityConsumer(maxResponseSize), maxResponseSize));
|
||||||
? null
|
FutureCallback<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> callback =
|
||||||
: toByteArray(
|
new FutureCallback<>() {
|
||||||
resp.getEntity(),
|
@Override
|
||||||
config.httpClient().maxResponseSize().asLongValue());
|
public void completed(Message<org.apache.hc.core5.http.HttpResponse, byte[]> result) {}
|
||||||
int code = resp.getCode();
|
|
||||||
String lastModifiedHeader =
|
|
||||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
|
||||||
.map(NameValuePair::getValue)
|
|
||||||
.map(StringUtils::trimToNull)
|
|
||||||
.orElse(null);
|
|
||||||
String eTagHeader =
|
|
||||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.ETAG))
|
|
||||||
.map(NameValuePair::getValue)
|
|
||||||
.map(StringUtils::trimToNull)
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
CacheControl cacheControl =
|
@Override
|
||||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
public void failed(Exception ex) {}
|
||||||
.map(NameValuePair::getValue)
|
|
||||||
.map(StringUtils::trimToNull)
|
|
||||||
.map(HttpGetter::toCacheControl)
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
Instant retryAfter =
|
@Override
|
||||||
Optional.ofNullable(resp.getFirstHeader(HttpHeaders.RETRY_AFTER))
|
public void cancelled() {}
|
||||||
.map(NameValuePair::getValue)
|
};
|
||||||
.map(StringUtils::trimToNull)
|
Future<Message<org.apache.hc.core5.http.HttpResponse, byte[]>> future =
|
||||||
.map(this::toInstant)
|
client.execute(req, resp, context, callback);
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
String contentType =
|
try {
|
||||||
Optional.ofNullable(resp.getEntity())
|
Message<org.apache.hc.core5.http.HttpResponse, byte[]> message =
|
||||||
.map(HttpEntity::getContentType)
|
future.get(config.httpClient().responseTimeout().toSeconds(), TimeUnit.SECONDS);
|
||||||
.orElse(null);
|
|
||||||
String urlAfterRedirect =
|
|
||||||
Optional.ofNullable(context.getRedirectLocations())
|
|
||||||
.map(RedirectLocations::getAll)
|
|
||||||
.map(l -> Iterables.getLast(l, null))
|
|
||||||
.map(URI::toString)
|
|
||||||
.orElse(request.getUrl());
|
|
||||||
|
|
||||||
return new HttpResponse(
|
byte[] content = message.getBody();
|
||||||
code,
|
int code = message.getHead().getCode();
|
||||||
lastModifiedHeader,
|
String lastModifiedHeader =
|
||||||
eTagHeader,
|
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.LAST_MODIFIED))
|
||||||
cacheControl,
|
.map(NameValuePair::getValue)
|
||||||
retryAfter,
|
.map(StringUtils::trimToNull)
|
||||||
content,
|
.orElse(null);
|
||||||
contentType,
|
String eTagHeader =
|
||||||
urlAfterRedirect);
|
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.ETAG))
|
||||||
});
|
.map(NameValuePair::getValue)
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
CacheControl cacheControl =
|
||||||
|
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CACHE_CONTROL))
|
||||||
|
.map(NameValuePair::getValue)
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.map(HttpGetter::toCacheControl)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
Instant retryAfter =
|
||||||
|
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.RETRY_AFTER))
|
||||||
|
.map(NameValuePair::getValue)
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.map(this::toInstant)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
String contentType =
|
||||||
|
Optional.ofNullable(message.getHead().getFirstHeader(HttpHeaders.CONTENT_TYPE))
|
||||||
|
.map(NameValuePair::getValue)
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.orElse(null);
|
||||||
|
String urlAfterRedirect =
|
||||||
|
Optional.ofNullable(context.getRedirectLocations())
|
||||||
|
.map(RedirectLocations::getAll)
|
||||||
|
.map(l -> Iterables.getLast(l, null))
|
||||||
|
.map(URI::toString)
|
||||||
|
.orElse(request.getUrl());
|
||||||
|
|
||||||
|
return new HttpResponse(
|
||||||
|
code,
|
||||||
|
lastModifiedHeader,
|
||||||
|
eTagHeader,
|
||||||
|
cacheControl,
|
||||||
|
retryAfter,
|
||||||
|
content,
|
||||||
|
contentType,
|
||||||
|
urlAfterRedirect);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
future.cancel(true);
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("interrupted while fetching " + request.getUrl(), e);
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
future.cancel(true);
|
||||||
|
throw new IOException("timed out while fetching " + request.getUrl(), e);
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
future.cancel(true);
|
||||||
|
throw Lombok.sneakyThrow(e.getCause());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CacheControl toCacheControl(String headerValue) {
|
private static CacheControl toCacheControl(String headerValue) {
|
||||||
@@ -237,28 +265,6 @@ public class HttpGetter {
|
|||||||
return DateUtils.parseStandardDate(headerValue);
|
return DateUtils.parseStandardDate(headerValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] toByteArray(HttpEntity entity, long maxBytes) throws IOException {
|
|
||||||
if (entity.getContentLength() > maxBytes) {
|
|
||||||
throw new IOException(
|
|
||||||
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
|
||||||
.formatted(entity.getContentLength(), maxBytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
try (InputStream input = entity.getContent()) {
|
|
||||||
if (input == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bytes = ByteStreams.limit(input, maxBytes + 1).readAllBytes();
|
|
||||||
if (bytes.length > maxBytes) {
|
|
||||||
throw new IOException(
|
|
||||||
"Response size exceeds the maximum allowed size (%s bytes)"
|
|
||||||
.formatted(maxBytes));
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
|
private static Cache<HttpRequest, HttpResponse> newCache(CommaFeedConfiguration config) {
|
||||||
HttpClientCache cacheConfig = config.httpClient().cache();
|
HttpClientCache cacheConfig = config.httpClient().cache();
|
||||||
if (!cacheConfig.enabled()) {
|
if (!cacheConfig.enabled()) {
|
||||||
@@ -266,14 +272,63 @@ public class HttpGetter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CacheBuilder.newBuilder()
|
return CacheBuilder.newBuilder()
|
||||||
.weigher(
|
.weigher((HttpRequest key, HttpResponse value) -> value.content() != null ? value.content().length : 0)
|
||||||
(HttpRequest key, HttpResponse value) ->
|
|
||||||
value.content() != null ? value.content().length : 0)
|
|
||||||
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
.maximumWeight(cacheConfig.maximumMemorySize().asLongValue())
|
||||||
.expireAfterWrite(cacheConfig.expiration())
|
.expireAfterWrite(cacheConfig.expiration())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record LimitedContentLengthEntityConsumer(@Delegate AsyncEntityConsumer<byte[]> delegate, long maxBytes)
|
||||||
|
implements AsyncEntityConsumer<byte[]> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void streamStart(EntityDetails entityDetails, FutureCallback<byte[]> resultCallback)
|
||||||
|
throws HttpException, IOException {
|
||||||
|
long contentLength = entityDetails.getContentLength();
|
||||||
|
if (contentLength > maxBytes) {
|
||||||
|
throw new IOException(
|
||||||
|
"Response size (%s bytes) exceeds the maximum allowed size (%s bytes)"
|
||||||
|
.formatted(contentLength, maxBytes));
|
||||||
|
}
|
||||||
|
delegate.streamStart(entityDetails, resultCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
private static class LimitedBytesEntityConsumer extends AbstractBinAsyncEntityConsumer<byte[]> {
|
||||||
|
|
||||||
|
private final long maxBytes;
|
||||||
|
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void streamStart(ContentType contentType) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int capacityIncrement() {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void data(ByteBuffer src, boolean endOfStream) throws IOException {
|
||||||
|
if (out.size() + src.remaining() > maxBytes) {
|
||||||
|
throw new IOException("Response size exceeds the maximum allowed size (%s bytes)".formatted(maxBytes));
|
||||||
|
}
|
||||||
|
byte[] chunk = new byte[src.remaining()];
|
||||||
|
src.get(chunk);
|
||||||
|
out.write(chunk, 0, chunk.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected byte[] generateContent() {
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseResources() {
|
||||||
|
out.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static class SchemeNotAllowedException extends Exception {
|
public static class SchemeNotAllowedException extends Exception {
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@@ -296,8 +351,7 @@ public class HttpGetter {
|
|||||||
this(message, null, null);
|
this(message, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public NotModifiedException(
|
public NotModifiedException(String message, String newLastModifiedHeader, String newEtagHeader) {
|
||||||
String message, String newLastModifiedHeader, String newEtagHeader) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.newLastModifiedHeader = newLastModifiedHeader;
|
this.newLastModifiedHeader = newLastModifiedHeader;
|
||||||
this.newEtagHeader = newEtagHeader;
|
this.newEtagHeader = newEtagHeader;
|
||||||
@@ -336,15 +390,15 @@ public class HttpGetter {
|
|||||||
return new HttpRequestBuilder().url(url);
|
return new HttpRequestBuilder().url(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClassicHttpRequest toClassicHttpRequest() {
|
public SimpleHttpRequest toSimpleHttpRequest() {
|
||||||
ClassicHttpRequest req = ClassicRequestBuilder.get(url).build();
|
SimpleRequestBuilder builder = SimpleRequestBuilder.get(url);
|
||||||
if (lastModified != null) {
|
if (lastModified != null) {
|
||||||
req.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
builder.addHeader(HttpHeaders.IF_MODIFIED_SINCE, lastModified);
|
||||||
}
|
}
|
||||||
if (eTag != null) {
|
if (eTag != null) {
|
||||||
req.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
builder.addHeader(HttpHeaders.IF_NONE_MATCH, eTag);
|
||||||
}
|
}
|
||||||
return req;
|
return builder.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ public class Urls {
|
|||||||
try {
|
try {
|
||||||
return URI.create(baseUrl).resolve(relativeUrl).toString();
|
return URI.create(baseUrl).resolve(relativeUrl).toString();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
log.debug(
|
log.debug("Unable to create absolute url from relative url: {} base: {}", relativeUrl, baseUrl, e);
|
||||||
"Unable to create absolute url from relative url: {} base: {}",
|
|
||||||
relativeUrl,
|
|
||||||
baseUrl,
|
|
||||||
e);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,8 +82,8 @@ public class Urls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to
|
* Normalize the url. The resulting url is not meant to be fetched but rather used as a mean to identify a feed and
|
||||||
* identify a feed and avoid duplicates
|
* avoid duplicates
|
||||||
*/
|
*/
|
||||||
public static String normalize(String url) {
|
public static String normalize(String url) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.commafeed.backend.dao;
|
package com.commafeed.backend.dao;
|
||||||
|
|
||||||
|
import com.commafeed.CommaFeedConfiguration;
|
||||||
import com.commafeed.backend.model.FeedCategory;
|
import com.commafeed.backend.model.FeedCategory;
|
||||||
import com.commafeed.backend.model.QFeedCategory;
|
import com.commafeed.backend.model.QFeedCategory;
|
||||||
import com.commafeed.backend.model.QUser;
|
import com.commafeed.backend.model.QUser;
|
||||||
@@ -17,8 +18,8 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
|||||||
|
|
||||||
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
|
private static final QFeedCategory CATEGORY = QFeedCategory.feedCategory;
|
||||||
|
|
||||||
public FeedCategoryDAO(EntityManager entityManager) {
|
public FeedCategoryDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||||
super(entityManager, FeedCategory.class);
|
super(entityManager, FeedCategory.class, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FeedCategory> findAll(User user) {
|
public List<FeedCategory> findAll(User user) {
|
||||||
@@ -30,9 +31,7 @@ public class FeedCategoryDAO extends GenericDAO<FeedCategory> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public FeedCategory findById(User user, Long id) {
|
public FeedCategory findById(User user, Long id) {
|
||||||
return query().selectFrom(CATEGORY)
|
return query().selectFrom(CATEGORY).where(CATEGORY.user.eq(user), CATEGORY.id.eq(id)).fetchOne();
|
||||||
.where(CATEGORY.user.eq(user), CATEGORY.id.eq(id))
|
|
||||||
.fetchOne();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public FeedCategory findByName(User user, String name, FeedCategory parent) {
|
public FeedCategory findByName(User user, String name, FeedCategory parent) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.commafeed.backend.dao;
|
package com.commafeed.backend.dao;
|
||||||
|
|
||||||
|
import com.commafeed.CommaFeedConfiguration;
|
||||||
import com.commafeed.backend.model.Feed;
|
import com.commafeed.backend.model.Feed;
|
||||||
import com.commafeed.backend.model.QFeed;
|
import com.commafeed.backend.model.QFeed;
|
||||||
import com.commafeed.backend.model.QFeedSubscription;
|
import com.commafeed.backend.model.QFeedSubscription;
|
||||||
@@ -20,8 +21,8 @@ public class FeedDAO extends GenericDAO<Feed> {
|
|||||||
private static final QFeed FEED = QFeed.feed;
|
private static final QFeed FEED = QFeed.feed;
|
||||||
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
|
private static final QFeedSubscription SUBSCRIPTION = QFeedSubscription.feedSubscription;
|
||||||
|
|
||||||
public FeedDAO(EntityManager entityManager) {
|
public FeedDAO(EntityManager entityManager, CommaFeedConfiguration config) {
|
||||||
super(entityManager, Feed.class);
|
super(entityManager, Feed.class, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Feed> findByIds(List<Long> id) {
|
public List<Feed> findByIds(List<Long> id) {
|
||||||
@@ -35,10 +36,7 @@ public class FeedDAO extends GenericDAO<Feed> {
|
|||||||
// join on subscriptions to only refresh feeds that have subscribers
|
// join on subscriptions to only refresh feeds that have subscribers
|
||||||
.join(SUBSCRIPTION)
|
.join(SUBSCRIPTION)
|
||||||
.on(SUBSCRIPTION.feed.eq(FEED))
|
.on(SUBSCRIPTION.feed.eq(FEED))
|
||||||
.where(
|
.where(FEED.disabledUntil.isNull().or(FEED.disabledUntil.lt(Instant.now())));
|
||||||
FEED.disabledUntil
|
|
||||||
.isNull()
|
|
||||||
.or(FEED.disabledUntil.lt(Instant.now())));
|
|
||||||
|
|
||||||
if (lastLoginThreshold != null) {
|
if (lastLoginThreshold != null) {
|
||||||
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
|
query.join(SUBSCRIPTION.user).where(SUBSCRIPTION.user.lastLogin.gt(lastLoginThreshold));
|
||||||
@@ -52,11 +50,7 @@ public class FeedDAO extends GenericDAO<Feed> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
|
public Feed findByUrl(String normalizedUrl, String normalizedUrlHash) {
|
||||||
return query()
|
return query().selectFrom(FEED).where(FEED.normalizedUrlHash.eq(normalizedUrlHash)).fetch().stream()
|
||||||
.selectFrom(FEED)
|
|
||||||
.where(FEED.normalizedUrlHash.eq(normalizedUrlHash))
|
|
||||||
.fetch()
|
|
||||||
.stream()
|
|
||||||
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
|
.filter(f -> Strings.CS.equals(normalizedUrl, f.getNormalizedUrl()))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user