Compare commits

...

95 Commits

Author SHA1 Message Date
3cefc0f176 Update docker script to build pgsql variant 2026-03-21 17:32:21 -05:00
1cb346866a Add script to build Docker image from forked code 2026-03-21 17:11:32 -05:00
5cc8c736e7 Add user preference to disable sidebar swipe-to-open on mobile; cleanup migrations + README in prep for long-term fork maintenance 2026-03-21 17:04:05 -05:00
eb5614a03b Merge branch 'master' of code.garrettmills.dev:garrettmills/commafeed 2026-03-21 16:39:28 -05:00
renovate[bot]
fc76d7e609 fix(deps): update dependency @rolldown/plugin-babel to ^0.2.2 2026-03-21 08:51:10 +00:00
renovate[bot]
1b24bf33ed fix(deps): update dependency com.google.protobuf:protobuf-java to v4.34.1 2026-03-20 18:02:04 +00:00
renovate[bot]
58ff378735 fix(deps): update dependency io.github.hakky54:ayza-for-apache5 to v10.0.4 2026-03-19 13:44:43 +00:00
Jérémie Panzer
1cee04a233 Merge pull request #2088 from Athou/renovate/com.diffplug.spotless-spotless-maven-plugin-3.x
chore(deps): update dependency com.diffplug.spotless:spotless-maven-plugin to v3.4.0
2026-03-19 00:03:44 +01:00
renovate[bot]
ac11a0efb8 chore(deps): update dependency com.diffplug.spotless:spotless-maven-plugin to v3.4.0 2026-03-18 21:29:01 +00:00
renovate[bot]
f2ea1e3f7a fix(deps): update dependency io.quarkus.platform:quarkus-bom to v3.32.4 2026-03-18 18:07:29 +00:00
Jérémie Panzer
153970c146 Merge pull request #2087 from Athou/renovate/jsdom-29.x
chore(deps): update dependency jsdom to v29
2026-03-18 13:46:24 +01:00
renovate[bot]
c21287e642 chore(deps): update dependency jsdom to v29 2026-03-18 12:13:28 +00:00
Athou
284942a82e add feature-request to the list of exempted labels 2026-03-18 13:00:44 +01:00
Jérémie Panzer
e796916e73 Merge pull request #2085 from Athou/renovate/com.ibm.icu-icu4j-78.x
fix(deps): update dependency com.ibm.icu:icu4j to v78.3
2026-03-18 12:42:41 +01:00
e2a1630adc Add Infrequent tab and corresponding user setting 2026-03-17 21:38:17 -05:00
renovate[bot]
004db1762c fix(deps): update dependency com.ibm.icu:icu4j to v78.3 2026-03-17 22:35:16 +00:00
renovate[bot]
8e05ba8820 chore(deps): update dependency lint-staged to ^16.4.0 (#2083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 20:33:50 +00:00
renovate[bot]
f4f386b5e5 chore(deps): update dependency lint-staged to ^16.3.4 2026-03-17 09:01:31 +00:00
Jérémie Panzer
ecbf8bec23 Merge pull request #2082 from Athou/renovate/debian-13.x
chore(deps): update debian docker tag to v13.4
2026-03-17 07:29:17 +01:00
renovate[bot]
5d8c09ccda chore(deps): update debian docker tag to v13.4 2026-03-17 00:35:37 +00:00
renovate[bot]
47c5c3d8a0 chore(deps): update dependency io.quarkiverse.playwright:quarkus-playwright to v2.3.3 2026-03-16 21:27:52 +00:00
renovate[bot]
3ddce16d5b chore(deps): update graalvm/setup-graalvm digest to 03e8abf 2026-03-16 18:35:24 +00:00
renovate[bot]
acefdc44d9 chore(deps): lock file maintenance 2026-03-16 01:07:15 +00:00
renovate[bot]
d05c5b9d7f chore(deps): update dependency npm to v11.11.1 2026-03-15 04:53:39 +00:00
renovate[bot]
5d1237d1f4 chore(deps): update ncipollo/release-action digest to 339a818 2026-03-15 01:34:06 +00:00
Athou
6bac8631ac output mantine as its own chunk because it is quite large 2026-03-14 20:20:51 +01:00
Athou
2c803327ad upgrade to vite 8 2026-03-13 22:35:20 +01:00
renovate[bot]
451ae5bc51 chore(deps): update dependency lint-staged to ^16.3.3 2026-03-13 05:46:24 +00:00
renovate[bot]
f41ce8c878 chore(deps): update graalvm/setup-graalvm digest to 6e327d2 2026-03-12 19:12:27 +00:00
renovate[bot]
177c54c813 chore(deps): update dependency maven to v3.9.14 2026-03-12 14:46:09 +00:00
Jérémie Panzer
3765e32fd4 Merge pull request #2079 from Athou/renovate/protobuf-monorepo
fix(deps): update dependency com.google.protobuf:protobuf-java to v4.34.0
2026-03-12 08:19:25 +01:00
renovate[bot]
aab7a16d18 fix(deps): update dependency com.google.protobuf:protobuf-java to v4.34.0 2026-03-12 06:26:40 +00:00
Athou
f2463af63c cel update 2026-03-12 07:25:36 +01:00
renovate[bot]
212c2c3b56 fix(deps): update quarkus.version to v3.32.3 2026-03-11 22:48:05 +00:00
renovate[bot]
3ab00b0cdd chore(deps): update actions/download-artifact digest to 3e5f45b 2026-03-11 17:53:34 +00:00
renovate[bot]
fad85e9299 fix(deps): update dependency org.projectlombok:lombok to v1.18.44 2026-03-11 02:48:12 +00:00
renovate[bot]
3cd5e203e2 fix(deps): update mantine monorepo to ^8.3.16 (#2076)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 08:40:30 +00:00
Jérémie Panzer
7b081fa870 Merge pull request #2077 from Athou/renovate/biomejs-biome-2.4.x
chore(deps): update dependency @biomejs/biome to v2.4.6
2026-03-09 09:08:37 +01:00
renovate[bot]
5ce22051d4 chore(deps): update dependency @biomejs/biome to v2.4.6 2026-03-09 07:19:20 +00:00
Jérémie Panzer
1e59489fa5 Merge pull request #2078 from Athou/renovate/lock-file-maintenance
chore(deps): lock file maintenance
2026-03-09 08:18:42 +01:00
renovate[bot]
1e691b1255 chore(deps): lock file maintenance 2026-03-09 01:10:36 +00:00
Jérémie Panzer
61e1bef63f Merge pull request #2074 from Athou/renovate/maven-3.9.x
chore(deps): update dependency maven to v3.9.13
2026-03-07 03:45:01 +01:00
renovate[bot]
46dbb78fbf chore(deps): update dependency maven to v3.9.13 2026-03-07 01:16:46 +00:00
renovate[bot]
99890707b3 chore(deps): update dependency lint-staged to ^16.3.2 2026-03-06 21:20:05 +00:00
Jérémie Panzer
f54c841fdc Merge pull request #2073 from Athou/renovate/react-icons-5.x
fix(deps): update dependency react-icons to ^5.6.0
2026-03-06 17:37:19 +01:00
renovate[bot]
e2a6009ee9 fix(deps): update dependency react-icons to ^5.6.0 2026-03-06 10:39:30 +00:00
Jérémie Panzer
a34dd15040 Merge pull request #2072 from Athou/renovate/docker-build-push-action-7.x
chore(deps): update docker/build-push-action action to v7
2026-03-06 07:20:22 +01:00
renovate[bot]
24c934003d chore(deps): update docker/build-push-action action to v7 2026-03-05 21:51:18 +00:00
renovate[bot]
5300e1a245 chore(deps): update dependency @biomejs/biome to v2.4.5 2026-03-05 18:43:33 +00:00
Jérémie Panzer
90381c670b Merge pull request #2071 from Athou/renovate/org.apache.maven.plugins-maven-resources-plugin-3.x
chore(deps): update dependency org.apache.maven.plugins:maven-resources-plugin to v3.5.0
2026-03-05 14:08:00 +01:00
renovate[bot]
23edea93db chore(deps): update dependency org.apache.maven.plugins:maven-resources-plugin to v3.5.0 2026-03-05 12:25:30 +00:00
Jérémie Panzer
a51712e363 Merge pull request #2070 from Athou/renovate/docker-setup-buildx-action-4.x
chore(deps): update docker/setup-buildx-action action to v4
2026-03-05 13:25:03 +01:00
renovate[bot]
4310e979e1 chore(deps): update docker/setup-buildx-action action to v4 2026-03-05 09:47:18 +00:00
renovate[bot]
f690b76d87 fix(deps): update quarkus.version to v3.32.2 2026-03-05 01:25:51 +00:00
Jérémie Panzer
ac29594b67 Merge pull request #2069 from Athou/renovate/docker-setup-qemu-action-4.x
chore(deps): update docker/setup-qemu-action action to v4
2026-03-04 15:29:50 +01:00
renovate[bot]
93f535bb87 chore(deps): update docker/setup-qemu-action action to v4 2026-03-04 13:38:20 +00:00
Jérémie Panzer
076eb3cf42 Merge pull request #2068 from Athou/renovate/docker-login-action-4.x
chore(deps): update docker/login-action action to v4
2026-03-04 14:35:47 +01:00
Athou
7b2e0fffbd reduce timeout for tests to speed up shutdown 2026-03-04 14:19:56 +01:00
renovate[bot]
8eaab0dbc3 chore(deps): update docker/login-action action to v4 2026-03-04 12:59:09 +00:00
Athou
eaa5bc896e stop the application between tests to make sure that there are no active transactions when we truncate the tables 2026-03-04 13:58:06 +01:00
renovate[bot]
42d1db5fc3 chore(deps): update dependency lint-staged to ^16.3.1 2026-03-04 12:09:08 +00:00
Athou
78c017ddaf wait for tasks to complete when shutting down 2026-03-04 12:49:06 +01:00
renovate[bot]
231551d743 chore(deps): update dependency lint-staged to ^16.3.0 (#2067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 21:56:21 +00:00
Jérémie Panzer
d0984eaba7 Merge pull request #2066 from Athou/renovate/com.diffplug.spotless-spotless-maven-plugin-3.x
chore(deps): update dependency com.diffplug.spotless:spotless-maven-plugin to v3.3.0
2026-03-03 06:05:51 +01:00
renovate[bot]
15854a72d1 chore(deps): update dependency com.diffplug.spotless:spotless-maven-plugin to v3.3.0 2026-03-03 01:56:14 +00:00
renovate[bot]
7fd2bf0eda fix(deps): update dependency axios to ^1.13.6 2026-03-02 18:15:22 +00:00
renovate[bot]
6a10a2167e chore(deps): lock file maintenance 2026-03-02 01:25:14 +00:00
Jérémie Panzer
1ba99d255c Merge pull request #2063 from Athou/renovate/npm-11.x
chore(deps): update dependency npm to v11.11.0
2026-03-01 00:43:13 +01:00
renovate[bot]
ff1c2947b6 chore(deps): update dependency npm to v11.11.0 2026-02-28 23:17:20 +00:00
Jérémie Panzer
a690d2e0db Merge pull request #2062 from Athou/renovate/com.puppycrawl.tools-checkstyle-13.x
chore(deps): update dependency com.puppycrawl.tools:checkstyle to v13.3.0
2026-03-01 00:16:55 +01:00
renovate[bot]
a3df327396 chore(deps): update dependency com.puppycrawl.tools:checkstyle to v13.3.0 2026-02-28 20:52:06 +00:00
Jérémie Panzer
ede0016d8e Merge pull request #2060 from Athou/renovate/major-github-artifact-actions
chore(deps): update github artifact actions (major)
2026-02-26 22:57:55 +01:00
renovate[bot]
c19b091795 chore(deps): update github artifact actions 2026-02-26 20:39:27 +00:00
renovate[bot]
9e62c8b9f3 fix(deps): update linguijs monorepo to ^5.9.2 2026-02-26 20:39:23 +00:00
renovate[bot]
9f30dc181c fix(deps): update dependency react-router-dom to ^7.13.1 2026-02-26 18:02:38 +00:00
Jérémie Panzer
37fe44f860 Merge pull request #2059 from Athou/renovate/quarkus.version
fix(deps): update quarkus.version to v3.32.1 (minor)
2026-02-25 20:23:16 +01:00
renovate[bot]
68aaab8467 fix(deps): update quarkus.version to v3.32.1 2026-02-25 16:48:25 +00:00
renovate[bot]
b951ed1fcd chore(deps): update debian:13.3 docker digest to 3615a74 2026-02-24 21:53:17 +00:00
Jérémie Panzer
9bd9dc568a Merge pull request #2058 from Athou/renovate/node-24.x
chore(deps): update node.js to v24.14.0
2026-02-24 21:18:33 +01:00
renovate[bot]
29e4356fee chore(deps): update node.js to v24.14.0 2026-02-24 17:22:57 +00:00
renovate[bot]
0ed31eaa99 chore(deps): update dependency @biomejs/biome to v2.4.4 2026-02-23 21:56:45 +00:00
Jérémie Panzer
1e9869b217 Merge pull request #2057 from Athou/renovate/npm-11.10.x
chore(deps): update dependency npm to v11.10.1
2026-02-23 07:21:35 +01:00
renovate[bot]
78cfc2c827 chore(deps): update dependency npm to v11.10.1 2026-02-23 05:33:44 +00:00
renovate[bot]
a6e5a0d125 chore(deps): lock file maintenance 2026-02-23 00:55:55 +00:00
renovate[bot]
ea13aecd27 chore(deps): update dependency @biomejs/biome to v2.4.3 2026-02-22 21:47:30 +00:00
Athou
d838e8f28f fix occasional flicker 2026-02-22 14:36:25 +01:00
Athou
c9a7b9e17c ensure the indicator is not shown above the app header 2026-02-22 11:47:13 +01:00
Athou
8fe2d0bc0e fix typo 2026-02-21 23:58:49 +01:00
Athou
71e2f1e1e6 kill biome on build to prevent unlink errors on Windows 2026-02-21 23:55:08 +01:00
Athou
1ce9d1b9b2 prevent title line wrapping 2026-02-21 23:39:38 +01:00
Athou
b3d6ae467f reduce code duplication 2026-02-21 23:24:03 +01:00
Athou
da8d720dc4 don't show a pointer on hover 2026-02-21 22:58:49 +01:00
Athou
824c38f8ce show unread count on mobile only 2026-02-21 22:52:32 +01:00
Athou
b0579a70d8 Merge branch 'master' of https://github.com/lpoirothattermann/commafeed into lpoirothattermann-master 2026-02-21 22:28:03 +01:00
Louis POIROT--HATTERMANN
f9fe2d0976 feat: add unread count badge next to title 2026-02-21 18:54:23 +01:00
40 changed files with 2199 additions and 2495 deletions

1
.github/stale.yml vendored
View File

@@ -7,6 +7,7 @@ exemptLabels:
- pinned
- security
- enhancement
- feature-request
- bug
# Label to use when marking an issue as stale
staleLabel: wontfix

View File

@@ -29,7 +29,7 @@ jobs:
# Setup
- name: Set up GraalVM
uses: graalvm/setup-graalvm@54b4f5a65c1a84b2fdfdc2078fe43df32819e4b1 # v1
uses: graalvm/setup-graalvm@03e8abf916fd0e281b2efe7b2da3378bb0a1d085 # v1
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
@@ -67,14 +67,14 @@ jobs:
# Upload artifacts
- name: Upload cross-platform app
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
if: matrix.os == 'ubuntu-latest' # we only need to upload the cross-platform artifact once per database
with:
name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip
- name: Upload native executable
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner*
@@ -104,17 +104,17 @@ jobs:
# Setup
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Install required packages
run: sudo apt-get install -y rename unzip
# Prepare artifacts
- name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: commafeed-${{ matrix.database }}-*
path: ./artifacts
@@ -135,7 +135,7 @@ jobs:
# Docker
- name: Login to Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
if: ${{ env.DOCKERHUB_USERNAME != '' }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -143,7 +143,7 @@ jobs:
## build but don't push for PRs and renovate
- name: Docker build - native
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
@@ -151,7 +151,7 @@ jobs:
platforms: linux/amd64,linux/arm64/v8
- name: Docker build - jvm
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
@@ -160,7 +160,7 @@ jobs:
## build and push tag
- name: Docker build and push tag - native
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
if: ${{ github.ref_type == 'tag' }}
with:
context: .
@@ -172,7 +172,7 @@ jobs:
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
- name: Docker build and push tag - jvm
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
if: ${{ github.ref_type == 'tag' }}
with:
context: .
@@ -185,7 +185,7 @@ jobs:
## build and push master
- name: Docker build and push master - native
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
if: ${{ github.ref_name == 'master' }}
with:
context: .
@@ -195,7 +195,7 @@ jobs:
tags: athou/commafeed:master-${{ matrix.database }}
- name: Docker build and push master - jvm
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
if: ${{ github.ref_name == 'master' }}
with:
context: .
@@ -220,7 +220,7 @@ jobs:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: commafeed-*
path: ./artifacts
@@ -236,7 +236,7 @@ jobs:
version: ${{ github.ref_name }}
- name: Create GitHub release
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
with:
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}

View File

@@ -1,3 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

22
README-fork.md Normal file
View File

@@ -0,0 +1,22 @@
# `garrettmills/commafeed`
This is my personal fork of `Athou/commafeed` with some tweaks:
- "Infrequent" tab - like "All" but limits to blogs w/ an average post interval greater than a user-configurable number of days
- User preference to disable the swipe-to-open-menu gesture on mobile
## Building
Use `gmfork-build-docker.sh` to build the JVM Docker image for `linux/amd64`:
You can use the `DB_VARIANT` env var to change which DB the image builds with. By default, it builds the `postgresql` variant.
```sh
DOCKER_REGISTRY=myregistry.example.com DB_VARIANT=h2 ./gmfork-build-docker.sh
```
To run locally:
```sh
docker run -p 8082:8082 $DOCKER_REGISTRY/commafeed-fork:latest
```

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,

File diff suppressed because it is too large Load Diff

View File

@@ -18,19 +18,20 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@fontsource/open-sans": "^5.2.7",
"@lingui/core": "^5.9.1",
"@lingui/react": "^5.9.1",
"@mantine/core": "^8.3.15",
"@mantine/form": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@mantine/modals": "^8.3.15",
"@mantine/notifications": "^8.3.15",
"@mantine/spotlight": "^8.3.15",
"@lingui/core": "^5.9.3",
"@lingui/react": "^5.9.3",
"@mantine/core": "^8.3.16",
"@mantine/form": "^8.3.16",
"@mantine/hooks": "^8.3.16",
"@mantine/modals": "^8.3.16",
"@mantine/notifications": "^8.3.16",
"@mantine/spotlight": "^8.3.16",
"@monaco-editor/react": "^4.7.0",
"@react-querybuilder/mantine": "^8.14.0",
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.13.5",
"dayjs": "^1.11.19",
"@rolldown/plugin-babel": "^0.2.2",
"axios": "^1.13.6",
"dayjs": "^1.11.20",
"escape-string-regexp": "^5.0.0",
"interweave": "^13.1.1",
"monaco-editor": "^0.55.1",
@@ -40,11 +41,11 @@
"react-contexify": "^6.0.0",
"react-dom": "^19.2.4",
"react-draggable": "^4.5.0",
"react-icons": "^5.5.0",
"react-icons": "^5.6.0",
"react-infinite-scroller": "^1.2.6",
"react-querybuilder": "^8.14.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.13.0",
"react-router-dom": "^7.13.1",
"react-swipeable": "^7.0.2",
"style-to-object": "^1.0.14",
"throttle-debounce": "^5.0.2",
@@ -53,10 +54,10 @@
"websocket-heartbeat-js": "^1.1.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.2",
"@lingui/babel-plugin-lingui-macro": "^5.9.1",
"@lingui/cli": "^5.9.1",
"@lingui/vite-plugin": "^5.9.1",
"@biomejs/biome": "^2.4.7",
"@lingui/babel-plugin-lingui-macro": "^5.9.3",
"@lingui/cli": "^5.9.3",
"@lingui/vite-plugin": "^5.9.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -66,16 +67,15 @@
"@types/react-infinite-scroller": "^1.2.5",
"@types/throttle-debounce": "^5.0.2",
"@types/tinycon": "^0.6.7",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "1.0.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
"lint-staged": "^16.2.7",
"jsdom": "^29.0.0",
"lint-staged": "^16.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite": "^8.0.0",
"vite-plugin-checker": "^0.12.0",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18",
"vitest": "^4.1.0",
"yaml": "^2.8.2"
},
"overrides": {

View File

@@ -13,9 +13,9 @@
<properties>
<!-- renovate: datasource=node-version depName=node -->
<node.version>v24.13.1</node.version>
<node.version>v24.14.0</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>11.10.0</npm.version>
<npm.version>11.11.1</npm.version>
</properties>
<build>
@@ -72,7 +72,7 @@
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.4.0</version>
<version>3.5.0</version>
<executions>
<execution>
<id>copy web interface to resources</id>
@@ -94,4 +94,49 @@
</plugin>
</plugins>
</build>
<profiles>
<!-- This profile is used to kill the Biome process on Windows -->
<!-- npm ci can fail if Biome is running (e.g., in the IDE) because it locks some files -->
<profile>
<id>kill-biome</id>
<activation>
<os>
<family>Windows</family>
</os>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<executions>
<execution>
<id>kill-biome</id>
<phase>initialize</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>taskkill</executable>
<arguments>
<argument>/F</argument>
<argument>/IM</argument>
<argument>biome.exe</argument>
</arguments>
<successCodes>
<successCode>0</successCode>
<!-- taskkill returns 128 if the process is not found, which is fine in this case -->
<successCode>128</successCode>
</successCodes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -18,6 +18,13 @@ const categories: Record<string, Omit<Category, "name">> = {
feeds: [],
position: 1,
},
infrequent: {
id: "infrequent",
expanded: false,
children: [],
feeds: [],
position: 2,
},
}
const sharing: {
@@ -105,6 +112,7 @@ export const Constants = {
tooltip: {
delay: 500,
},
infrequentThresholdDaysDefault: 7,
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
customCssDocumentationUrl: "https://athou.github.io/commafeed/documentation/custom-css",
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",

View File

@@ -1,5 +1,5 @@
import { configureStore } from "@reduxjs/toolkit"
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import { shallowEqual, type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import { entriesSlice } from "@/app/entries/slice"
import { redirectSlice } from "@/app/redirect/slice"
import { serverSlice } from "@/app/server/slice"
@@ -41,3 +41,4 @@ export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useShallowEqualAppSelector: TypedUseSelectorHook<RootState> = selector => useSelector(selector, shallowEqual)

View File

@@ -31,6 +31,7 @@ export interface Subscription {
filterLegacy?: string
pushNotificationsEnabled: boolean
autoMarkAsReadAfterDays?: number
averageEntryIntervalMs?: number
}
export interface Category {
@@ -284,6 +285,8 @@ export interface Settings {
unreadCountTitle: boolean
unreadCountFavicon: boolean
disablePullToRefresh: boolean
disableMobileSwipe: boolean
infrequentThresholdDays: number
primaryColor?: string
sharingSettings: SharingSettings
pushNotificationSettings: PushNotificationSettings

View File

@@ -4,9 +4,11 @@ import { createSlice, isAnyOf, type PayloadAction } from "@reduxjs/toolkit"
import type { LocalSettings, Settings, UserModel, ViewMode } from "@/app/types"
import {
changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -141,6 +143,14 @@ export const userSlice = createSlice({
if (!state.settings) return
state.settings.disablePullToRefresh = action.meta.arg
})
builder.addCase(changeDisableMobileSwipe.pending, (state, action) => {
if (!state.settings) return
state.settings.disableMobileSwipe = action.meta.arg
})
builder.addCase(changeInfrequentThresholdDays.pending, (state, action) => {
if (!state.settings) return
state.settings.infrequentThresholdDays = action.meta.arg
})
builder.addCase(changePrimaryColor.pending, (state, action) => {
if (!state.settings) return
state.settings.primaryColor = action.meta.arg
@@ -171,6 +181,8 @@ export const userSlice = createSlice({
changeUnreadCountTitle.fulfilled,
changeUnreadCountFavicon.fulfilled,
changeDisablePullToRefresh.fulfilled,
changeDisableMobileSwipe.fulfilled,
changeInfrequentThresholdDays.fulfilled,
changePrimaryColor.fulfilled,
changeSharingSetting.fulfilled,
changePushNotificationSettings.fulfilled

View File

@@ -131,6 +131,12 @@ export const changeDisablePullToRefresh = createAppAsyncThunk(
}
)
export const changeDisableMobileSwipe = createAppAsyncThunk("settings/disableMobileSwipe", (disableMobileSwipe: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, disableMobileSwipe })
})
export const changePrimaryColor = createAppAsyncThunk("settings/primaryColor", (primaryColor: string, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
@@ -158,6 +164,15 @@ export const changeSharingSetting = createAppAsyncThunk(
}
)
export const changeInfrequentThresholdDays = createAppAsyncThunk(
"settings/infrequentThresholdDays",
(infrequentThresholdDays: number, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, infrequentThresholdDays })
}
)
export const changePushNotificationSettings = createAppAsyncThunk(
"settings/pushNotificationSettings",
(pushNotificationSettings: PushNotificationSettings, thunkApi) => {

View File

@@ -26,20 +26,22 @@ export function flattenCategoryTree(category: TreeCategory): TreeCategory[] {
return categories
}
export function categoryUnreadCount(category?: TreeCategory): number {
export function categoryUnreadCount(category?: TreeCategory, maxFrequencyThresholdMs?: number): number {
if (!category) return 0
return flattenCategoryTree(category)
.flatMap(c => c.feeds)
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
.map(f => f.unread)
.reduce((total, current) => total + current, 0)
}
export function categoryHasNewEntries(category?: TreeCategory): boolean {
export function categoryHasNewEntries(category?: TreeCategory, maxFrequencyThresholdMs?: number): boolean {
if (!category) return false
return flattenCategoryTree(category)
.flatMap(c => c.feeds)
.filter(f => !maxFrequencyThresholdMs || (f.averageEntryIntervalMs && f.averageEntryIntervalMs >= maxFrequencyThresholdMs))
.some(f => f.hasNewEntries)
}

View File

@@ -9,9 +9,11 @@ import { useAppDispatch, useAppSelector } from "@/app/store"
import type { IconDisplayMode, ScrollMode, SharingSettings } from "@/app/types"
import {
changeCustomContextMenu,
changeDisableMobileSwipe,
changeDisablePullToRefresh,
changeEntriesToKeepOnTopWhenScrolling,
changeExternalLinkIconDisplayMode,
changeInfrequentThresholdDays,
changeLanguage,
changeMarkAllAsReadConfirmation,
changeMarkAllAsReadNavigateToUnread,
@@ -44,6 +46,8 @@ export function DisplaySettings() {
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
const disablePullToRefresh = useAppSelector(state => state.user.settings?.disablePullToRefresh)
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
const infrequentThresholdDays = useAppSelector(state => state.user.settings?.infrequentThresholdDays)
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const primaryColor = useAppSelector(state => state.user.settings?.primaryColor) || Constants.theme.defaultPrimaryColor
const { _ } = useLingui()
@@ -143,6 +147,20 @@ export function DisplaySettings() {
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
/>
<Switch
label={<Trans>On mobile, disable swipe gesture to open the menu</Trans>}
checked={disableMobileSwipe}
onChange={async e => await dispatch(changeDisableMobileSwipe(e.currentTarget.checked))}
/>
<NumberInput
label={<Trans>Infrequent posts threshold (days)</Trans>}
description={<Trans>Feeds posting less often than this (on average) will appear in the Infrequent view</Trans>}
min={1}
value={infrequentThresholdDays}
onChange={async value => await dispatch(changeInfrequentThresholdDays(+value))}
/>
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
<Switch

View File

@@ -1,7 +1,7 @@
import { Trans } from "@lingui/react/macro"
import { Box, Stack } from "@mantine/core"
import React from "react"
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { TbChevronDown, TbChevronRight, TbClock, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { Constants } from "@/app/constants"
import {
redirectToCategory,
@@ -23,6 +23,7 @@ import { TreeSearch } from "./TreeSearch"
const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} />
const infrequentIcon = <TbClock size={16} />
const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} />
@@ -34,6 +35,10 @@ export function Tree() {
const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead)
const infrequentThresholdDays = useAppSelector(
state => state.user.settings?.infrequentThresholdDays ?? Constants.infrequentThresholdDaysDefault
)
const infrequentThresholdMs = infrequentThresholdDays * 24 * 3600 * 1000
const dispatch = useAppDispatch()
const isFeedDisplayed = (feed: Subscription) => {
@@ -115,6 +120,22 @@ export function Tree() {
onClick={categoryClicked}
/>
)
const infrequentCategoryNode = () => (
<TreeNode
id={Constants.categories.infrequent.id}
type="category"
name={<Trans>Infrequent</Trans>}
icon={infrequentIcon}
unread={categoryUnreadCount(root, infrequentThresholdMs)}
hasNewEntries={categoryHasNewEntries(root, infrequentThresholdMs)}
selected={source.type === "category" && source.id === Constants.categories.infrequent.id}
expanded={false}
level={0}
hasError={false}
hasWarning={false}
onClick={categoryClicked}
/>
)
const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null
@@ -197,6 +218,7 @@ export function Tree() {
<Box className="cf-tree">
{allCategoryNode()}
{starredCategoryNode()}
{infrequentCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))}
{tags?.map(tag => tagNode(tag))}

View File

@@ -6,7 +6,11 @@ const useStyles = tss.create(() => ({
badge: {
width: "3.2rem",
// for some reason, mantine Badge has "cursor: 'default'"
cursor: "pointer",
cursor: "inherit",
},
indicator: {
// ensure the indicator is not shown above the app header
zIndex: 0,
},
}))
@@ -23,7 +27,15 @@ export function UnreadCount(
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
return (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
<Indicator disabled={!props.showIndicator} size={4} offset={10} position="middle-start">
<Indicator
disabled={!props.showIndicator}
size={4}
offset={10}
position="middle-start"
classNames={{
indicator: classes.indicator,
}}
>
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
{count}
</Badge>

View File

@@ -405,6 +405,10 @@ msgstr "Feed name"
msgid "Feed URL"
msgstr "Feed URL"
#: src/components/settings/DisplaySettings.tsx
msgid "Feeds posting less often than this (on average) will appear in the Infrequent view"
msgstr "Feeds posting less often than this (on average) will appear in the Infrequent view"
#: src/components/header/ProfileMenu.tsx
msgid "Fetch all my feeds now"
msgstr "Fetch all my feeds now"
@@ -502,6 +506,14 @@ msgstr "In expanded view, scrolling through entries mark them as read"
msgid "Indigo"
msgstr "Indigo"
#: src/components/sidebar/Tree.tsx
msgid "Infrequent"
msgstr "Infrequent"
#: src/components/settings/DisplaySettings.tsx
msgid "Infrequent posts threshold (days)"
msgstr "Infrequent posts threshold (days)"
#: src/pages/auth/InitialSetupPage.tsx
msgid "Initial Setup"
msgstr "Initial Setup"
@@ -703,6 +715,10 @@ msgstr "On desktop"
msgid "On mobile"
msgstr "On mobile"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile, disable swipe gesture to open the menu"
msgstr "On mobile, disable swipe gesture to open the menu"
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "On mobile, show action buttons at the bottom of the screen"

View File

@@ -8,9 +8,11 @@ import { Constants } from "@/app/constants"
import type { EntrySourceType } from "@/app/entries/slice"
import { loadEntries } from "@/app/entries/thunks"
import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "@/app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "@/app/store"
import { flattenCategoryTree } from "@/app/utils"
import { useAppDispatch, useAppSelector, useShallowEqualAppSelector } from "@/app/store"
import { categoryHasNewEntries, categoryUnreadCount, flattenCategoryTree } from "@/app/utils"
import { FeedEntries } from "@/components/content/FeedEntries"
import { UnreadCount } from "@/components/sidebar/UnreadCount"
import { useMobile } from "@/hooks/useMobile"
import { tss } from "@/tss"
function NoSubscriptionHelp() {
@@ -33,6 +35,12 @@ const useStyles = tss.create(() => ({
sourceWebsiteLink: {
color: "inherit",
textDecoration: "none",
overflow: "hidden",
},
titleText: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}))
@@ -48,6 +56,33 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
const hasMore = useAppSelector(state => state.entries.hasMore)
const mobile = useMobile()
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
const { unreadCount, hasNewEntries } = useShallowEqualAppSelector(state => {
const root = state.tree.rootCategory
if (!root) return { unreadCount: 0, hasNewEntries: false }
if (props.sourceType === "category") {
const category = id === Constants.categories.all.id ? root : flattenCategoryTree(root).find(c => c.id === id)
return {
unreadCount: categoryUnreadCount(category),
hasNewEntries: categoryHasNewEntries(category),
}
}
if (props.sourceType === "feed") {
const feed = flattenCategoryTree(root)
.flatMap(c => c.feeds)
.find(f => f.id === +id)
return {
unreadCount: feed?.unread ?? 0,
hasNewEntries: !!feed?.hasNewEntries,
}
}
return { unreadCount: 0, hasNewEntries: false }
})
const showUnreadCount = mobile || !sidebarVisible
const dispatch = useAppDispatch()
let title: React.ReactNode = sourceLabel
@@ -89,16 +124,23 @@ export function FeedEntriesPage(props: Readonly<FeedEntriesPageProps>) {
return (
// add some room at the bottom of the page in order to be able to scroll the current entry at the top of the page when expanding
<Box mb={viewport.height * 0.7}>
<Group gap="xl" className="cf-entries-title">
<Group className="cf-entries-title" wrap="nowrap">
{sourceWebsiteUrl && (
<a href={sourceWebsiteUrl} target="_blank" rel="noreferrer" className={classes.sourceWebsiteLink}>
<Title order={3}>{title}</Title>
<Title order={3} className={classes.titleText}>
{title}
</Title>
</a>
)}
{!sourceWebsiteUrl && <Title order={3}>{title}</Title>}
{!sourceWebsiteUrl && (
<Title order={3} className={classes.titleText}>
{title}
</Title>
)}
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} />
</ActionIcon>
{showUnreadCount && <UnreadCount unreadCount={unreadCount} showIndicator={hasNewEntries} />}
</Group>
<FeedEntries />

View File

@@ -79,6 +79,7 @@ export default function Layout(props: Readonly<LayoutProps>) {
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const disableMobileSwipe = useAppSelector(state => state.user.settings?.disableMobileSwipe)
const sidebarWidth = useAppSelector(state => state.user.localSettings.sidebarWidth)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
@@ -164,6 +165,9 @@ export default function Layout(props: Readonly<LayoutProps>) {
const swipeHandlers = useSwipeable({
onSwiping: e => {
if (disableMobileSwipe) {
return
}
const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right"))

View File

@@ -1,25 +1,17 @@
import { lingui } from "@lingui/vite-plugin"
import react from "@vitejs/plugin-react"
import babel from "@rolldown/plugin-babel"
import react, { reactCompilerPreset } from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import checker from "vite-plugin-checker"
import tsconfigPaths from "vite-tsconfig-paths"
// https://vitejs.dev/config/
export default defineConfig(() => ({
plugins: [
react({
babel: {
plugins: [
// support for lingui macros
// needs to be before the react compiler plugin
"@lingui/babel-plugin-lingui-macro",
// react compiler
["babel-plugin-react-compiler", { target: "19" }],
],
},
react(),
babel({
presets: [reactCompilerPreset()],
plugins: ["@lingui/babel-plugin-lingui-macro"],
}),
lingui(),
tsconfigPaths(),
checker({
typescript: true,
biome: {
@@ -43,22 +35,32 @@ export default defineConfig(() => ({
"/logout": "http://localhost:8083",
},
},
build: {
chunkSizeWarningLimit: 3500,
rollupOptions: {
output: {
manualChunks: id => {
// output mantine as its own chunk because it is quite large
if (id.includes("@mantine")) {
return "mantine"
}
},
},
},
resolve: {
tsconfigPaths: true,
},
legacy: {
// required for websocket-heartbeat-js
inconsistentCjsInterop: true,
},
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/setupTests.ts",
},
build: {
chunkSizeWarningLimit: 4000,
rolldownOptions: {
output: {
codeSplitting: {
groups: [
// output mantine as its own chunk because it is quite large
{
name: "mantine",
test: "@mantine",
},
],
},
},
},
},
}))

View File

@@ -13,7 +13,7 @@
<packaging>quarkus</packaging>
<properties>
<quarkus.version>3.31.4</quarkus.version>
<quarkus.version>3.32.4</quarkus.version>
<querydsl.version>7.1</querydsl.version>
<rome.version>2.1.0</rome.version>
@@ -29,6 +29,12 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- the quarkus bom declares a dependency on an old version of protobuf, we need to override it for cel-java -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>4.34.1</version>
</dependency>
</dependencies>
</dependencyManagement>
@@ -237,7 +243,7 @@
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>13.2.0</version>
<version>13.3.0</version>
</dependency>
</dependencies>
<executions>
@@ -266,7 +272,7 @@
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>3.2.1</version>
<version>3.4.0</version>
<?m2e ignore?>
<executions>
<execution>
@@ -302,7 +308,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
<version>1.18.44</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -393,7 +399,7 @@
<dependency>
<groupId>dev.cel</groupId>
<artifactId>cel</artifactId>
<version>0.11.1</version>
<version>0.12.0</version>
</dependency>
<dependency>
<groupId>org.passay</groupId>
@@ -428,7 +434,7 @@
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j</artifactId>
<version>78.2</version>
<version>78.3</version>
</dependency>
<dependency>
<groupId>net.sourceforge.cssparser</groupId>
@@ -448,7 +454,7 @@
<dependency>
<groupId>io.github.hakky54</groupId>
<artifactId>ayza-for-apache5</artifactId>
<version>10.0.3</version>
<version>10.0.4</version>
</dependency>
<dependency>
<groupId>org.brotli</groupId>
@@ -465,7 +471,7 @@
<dependency>
<groupId>io.quarkiverse.playwright</groupId>
<artifactId>quarkus-playwright</artifactId>
<version>2.3.2</version>
<version>2.3.3</version>
<scope>test</scope>
</dependency>
<dependency>

View File

@@ -1,4 +1,4 @@
FROM debian:13.3@sha256:2c91e484d93f0830a7e05a2b9d92a7b102be7cab562198b984a84fdbc7806d91
FROM debian:13.4@sha256:55a15a112b42be10bfc8092fcc40b6748dc236f7ef46a358d9392b339e9d60e8
ARG TARGETARCH
EXPOSE 8082

View File

@@ -10,7 +10,9 @@ import com.commafeed.security.password.PasswordConstraintValidator;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
@RequiredArgsConstructor
public class CommaFeedApplication {
@@ -20,6 +22,8 @@ public class CommaFeedApplication {
private final CommaFeedConfiguration config;
public void start(@Observes StartupEvent ev) {
log.info("starting up...");
PasswordConstraintValidator.setMinimumPasswordLength(config.users().minimumPasswordLength());
feedRefreshEngine.start();
@@ -27,6 +31,8 @@ public class CommaFeedApplication {
}
public void stop(@Observes ShutdownEvent ev) {
log.info("shutting down...");
feedRefreshEngine.stop();
taskScheduler.stop();
}

View File

@@ -92,6 +92,12 @@ public interface CommaFeedConfiguration {
@ConfigDocSection
Websocket websocket();
/**
* Duration to wait for the feed refresh engine and the task scheduler to stop when the application is shutting down.
*/
@WithDefault("2s")
Duration shutdownTimeout();
interface HttpClient {
/**
* User-Agent string that will be used by the http client, leave empty for the default one.
@@ -144,7 +150,7 @@ public interface CommaFeedConfiguration {
* Prevent access to local addresses to mitigate server-side request forgery (SSRF) attacks, which could potentially expose internal
* resources.
*
* You may want to enable this if you host a public instance of CommaFeed with regisration open.
* You may want to enable this if you host a public instance of CommaFeed with registrations open.
*/
@WithDefault("false")
boolean blockLocalAddresses();

View File

@@ -24,6 +24,7 @@ import com.commafeed.backend.model.AbstractModel;
import com.commafeed.backend.model.Feed;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedSubscription;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j;
@@ -41,12 +42,12 @@ public class FeedRefreshEngine {
private final BlockingDeque<Feed> queue;
private final ExecutorService feedProcessingLoopExecutor;
private final ExecutorService refillLoopExecutor;
private final ExecutorService refillExecutor;
private final ThreadPoolExecutor workerExecutor;
private final ThreadPoolExecutor databaseUpdaterExecutor;
private final ThreadPoolExecutor notifierExecutor;
private ExecutorService feedProcessingLoopExecutor;
private ExecutorService refillLoopExecutor;
private ThreadPoolExecutor refillExecutor;
private ThreadPoolExecutor workerExecutor;
private ThreadPoolExecutor databaseUpdaterExecutor;
private ThreadPoolExecutor notifierExecutor;
public FeedRefreshEngine(UnitOfWork unitOfWork, FeedDAO feedDAO, FeedRefreshWorker worker, FeedRefreshUpdater updater,
FeedUpdateNotifier notifier, CommaFeedConfiguration config, MetricRegistry metrics) {
@@ -60,6 +61,15 @@ public class FeedRefreshEngine {
this.queue = new LinkedBlockingDeque<>();
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge<Integer>) () -> workerExecutor.getActiveCount());
metrics.register(MetricRegistry.name(getClass(), "updater", "active"),
(Gauge<Integer>) () -> databaseUpdaterExecutor.getActiveCount());
metrics.register(MetricRegistry.name(getClass(), "notifier", "active"), (Gauge<Integer>) () -> notifierExecutor.getActiveCount());
metrics.register(MetricRegistry.name(getClass(), "notifier", "queue"), (Gauge<Integer>) () -> notifierExecutor.getQueue().size());
}
private void createExecutors() {
this.feedProcessingLoopExecutor = Executors.newSingleThreadExecutor();
this.refillLoopExecutor = Executors.newSingleThreadExecutor();
this.refillExecutor = newDiscardingSingleThreadExecutorService();
@@ -67,15 +77,10 @@ public class FeedRefreshEngine {
this.databaseUpdaterExecutor = newBlockingExecutorService(config.feedRefresh().databaseThreads());
this.notifierExecutor = newDiscardingExecutorService(config.pushNotifications().threads(),
config.pushNotifications().queueCapacity());
metrics.register(MetricRegistry.name(getClass(), "queue", "size"), (Gauge<Integer>) queue::size);
metrics.register(MetricRegistry.name(getClass(), "worker", "active"), (Gauge<Integer>) workerExecutor::getActiveCount);
metrics.register(MetricRegistry.name(getClass(), "updater", "active"), (Gauge<Integer>) databaseUpdaterExecutor::getActiveCount);
metrics.register(MetricRegistry.name(getClass(), "notifier", "active"), (Gauge<Integer>) notifierExecutor::getActiveCount);
metrics.register(MetricRegistry.name(getClass(), "notifier", "queue"), (Gauge<Integer>) () -> notifierExecutor.getQueue().size());
}
public void start() {
createExecutors();
startFeedProcessingLoop();
startRefillLoop();
}
@@ -197,12 +202,14 @@ public class FeedRefreshEngine {
}
public void stop() {
this.feedProcessingLoopExecutor.shutdownNow();
this.refillLoopExecutor.shutdownNow();
this.refillExecutor.shutdownNow();
this.workerExecutor.shutdownNow();
this.databaseUpdaterExecutor.shutdownNow();
this.notifierExecutor.shutdownNow();
MoreExecutors.shutdownAndAwaitTermination(this.feedProcessingLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillLoopExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.refillExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.workerExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.databaseUpdaterExecutor, config.shutdownTimeout());
MoreExecutors.shutdownAndAwaitTermination(this.notifierExecutor, config.shutdownTimeout());
queue.clear();
}
/**

View File

@@ -98,7 +98,7 @@ public class Feed extends AbstractModel {
private String etagHeader;
/**
* average time between entries in the feed
* average time between entries in the feed in milliseconds
*/
private Long averageEntryInterval;

View File

@@ -145,6 +145,9 @@ public class UserSettings extends AbstractModel {
private boolean unreadCountTitle;
private boolean unreadCountFavicon;
private boolean disablePullToRefresh;
private boolean disableMobileSwipe;
private int infrequentThresholdDays;
private boolean email;
private boolean gmail;

View File

@@ -54,10 +54,20 @@ public class DatabaseCleaningService {
int deleted;
long entriesTotal = 0;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of feeds without subscriptions");
return;
}
List<Feed> feeds = unitOfWork.call(() -> feedDAO.findWithoutSubscriptions(1));
for (Feed feed : feeds) {
long entriesDeleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of feeds without subscriptions");
return;
}
entriesDeleted = unitOfWork.call(() -> feedEntryDAO.delete(feed.getId(), batchSize));
entriesDeletedMeter.mark(entriesDeleted);
entriesTotal += entriesDeleted;
@@ -76,6 +86,11 @@ public class DatabaseCleaningService {
long total = 0;
long deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of contents without entries");
return;
}
deleted = unitOfWork.call(() -> feedEntryContentDAO.deleteWithoutEntries(batchSize));
total += deleted;
log.debug("removed {} contents without entries", total);
@@ -87,6 +102,11 @@ public class DatabaseCleaningService {
log.info("cleaning entries exceeding feed capacity");
long total = 0;
while (true) {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of entries exceeding feed capacity");
return;
}
List<FeedCapacity> feeds = unitOfWork
.call(() -> feedEntryDAO.findFeedsExceedingCapacity(maxFeedCapacity, batchSize, keepStarredEntries));
if (feeds.isEmpty()) {
@@ -97,6 +117,11 @@ public class DatabaseCleaningService {
long remaining = feed.capacity() - maxFeedCapacity;
int deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of entries exceeding feed capacity");
return;
}
final long rem = remaining;
deleted = unitOfWork.call(() -> feedEntryDAO.deleteOldEntries(feed.id(), Math.min(batchSize, rem), keepStarredEntries));
entriesDeletedMeter.mark(deleted);
@@ -114,6 +139,11 @@ public class DatabaseCleaningService {
long total = 0;
long deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of old entries");
return;
}
deleted = unitOfWork.call(() -> feedEntryDAO.deleteEntriesOlderThan(olderThan, batchSize, keepStarredEntries));
entriesDeletedMeter.mark(deleted);
total += deleted;
@@ -127,6 +157,11 @@ public class DatabaseCleaningService {
long total = 0;
long deleted;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping cleanup of old read statuses");
return;
}
deleted = unitOfWork.call(() -> feedEntryStatusDAO.deleteOldStatuses(olderThan, batchSize));
total += deleted;
log.debug("removed {} old read statuses", total);
@@ -139,6 +174,11 @@ public class DatabaseCleaningService {
long total = 0;
long marked;
do {
if (Thread.currentThread().isInterrupted()) {
log.info("interrupted, stopping marking entries as read");
return;
}
marked = unitOfWork.call(() -> feedEntryStatusDAO.autoMarkAsRead(batchSize));
total += marked;
log.debug("marked {} entries as read", total);

View File

@@ -23,8 +23,8 @@ public abstract class ScheduledTask {
log.error(e.getMessage(), e);
}
};
log.info("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(), getTimeUnit(),
getInitialDelay(), getTimeUnit());
log.debug("registering task {} for execution every {} {}, starting in {} {}", getClass().getSimpleName(), getPeriod(),
getTimeUnit(), getInitialDelay(), getTimeUnit());
executor.scheduleWithFixedDelay(runnable, getInitialDelay(), getPeriod(), getTimeUnit());
}
}

View File

@@ -6,24 +6,32 @@ import java.util.concurrent.ScheduledExecutorService;
import jakarta.inject.Singleton;
import io.quarkus.arc.All;
import com.commafeed.CommaFeedConfiguration;
import com.google.common.util.concurrent.MoreExecutors;
import io.quarkus.arc.All;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Singleton
public class TaskScheduler {
private final List<ScheduledTask> tasks;
private final ScheduledExecutorService executor;
private final CommaFeedConfiguration config;
public TaskScheduler(@All List<ScheduledTask> tasks) {
private ScheduledExecutorService executor;
public TaskScheduler(@All List<ScheduledTask> tasks, CommaFeedConfiguration config) {
this.tasks = tasks;
this.executor = Executors.newScheduledThreadPool(tasks.size());
this.config = config;
}
public void start() {
tasks.forEach(task -> task.register(executor));
this.executor = Executors.newScheduledThreadPool(tasks.size());
this.tasks.forEach(task -> task.register(executor));
}
public void stop() {
executor.shutdownNow();
MoreExecutors.shutdownAndAwaitTermination(executor, config.shutdownTimeout());
}
}

View File

@@ -76,6 +76,12 @@ public class Settings implements Serializable {
@Schema(description = "disable pull to refresh", required = true)
private boolean disablePullToRefresh;
@Schema(description = "disable swipe gesture to open mobile menu", required = true)
private boolean disableMobileSwipe;
@Schema(description = "threshold in days for the infrequent view", required = true)
private int infrequentThresholdDays;
@Schema(description = "primary theme color to use in the UI")
private String primaryColor;

View File

@@ -71,6 +71,9 @@ public class Subscription implements Serializable {
@Schema(description = "automatically mark entries as read after this many days (null to disable)")
private Integer autoMarkAsReadAfterDays;
@Schema(description = "average time in milliseconds between entries in this feed, null if unknown")
private Long averageEntryIntervalMs;
public static Subscription build(FeedSubscription subscription, UnreadCount unreadCount) {
FeedCategory category = subscription.getCategory();
Feed feed = subscription.getFeed();
@@ -93,6 +96,7 @@ public class Subscription implements Serializable {
sub.setFilterLegacy(subscription.getFilterLegacy());
sub.setPushNotificationsEnabled(subscription.isPushNotificationsEnabled());
sub.setAutoMarkAsReadAfterDays(subscription.getAutoMarkAsReadAfterDays());
sub.setAverageEntryIntervalMs(feed.getAverageEntryInterval());
return sub;
}

View File

@@ -40,12 +40,14 @@ import com.commafeed.CommaFeedConfiguration;
import com.commafeed.backend.dao.FeedCategoryDAO;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.dao.UserSettingsDAO;
import com.commafeed.backend.feed.FeedEntryKeyword;
import com.commafeed.backend.feed.FeedUtils;
import com.commafeed.backend.model.FeedCategory;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.UserSettings;
import com.commafeed.backend.model.UserSettings.ReadingMode;
import com.commafeed.backend.model.UserSettings.ReadingOrder;
import com.commafeed.backend.service.FeedEntryService;
@@ -83,6 +85,7 @@ public class CategoryREST {
public static final String ALL = "all";
public static final String STARRED = "starred";
public static final String INFREQUENT = "infrequent";
private final AuthenticationContext authenticationContext;
private final FeedCategoryDAO feedCategoryDAO;
@@ -90,6 +93,7 @@ public class CategoryREST {
private final FeedSubscriptionDAO feedSubscriptionDAO;
private final FeedEntryService feedEntryService;
private final FeedSubscriptionService feedSubscriptionService;
private final UserSettingsDAO userSettingsDAO;
private final CommaFeedConfiguration config;
private final UriInfo uri;
@@ -139,11 +143,15 @@ public class CategoryREST {
}
User user = authenticationContext.getCurrentUser();
if (ALL.equals(id)) {
if (ALL.equals(id) || INFREQUENT.equals(id)) {
entries.setName(Optional.ofNullable(tag).orElse("All"));
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, excludedIds);
if (INFREQUENT.equals(id)) {
entries.setName("Infrequent");
removeFrequentSubscriptions(subs, user);
}
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(user, subs, unreadOnly, entryKeywords, newerThanDate,
offset, limit + 1, order, true, tag, null, null);
@@ -244,9 +252,12 @@ public class CategoryREST {
List<FeedEntryKeyword> entryKeywords = FeedEntryKeyword.fromQueryString(keywords);
User user = authenticationContext.getCurrentUser();
if (ALL.equals(req.getId())) {
if (ALL.equals(req.getId()) || INFREQUENT.equals(req.getId())) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
removeExcludedSubscriptions(subs, req.getExcludedSubscriptions());
if (INFREQUENT.equals(req.getId())) {
removeFrequentSubscriptions(subs, user);
}
feedEntryService.markSubscriptionEntries(user, subs, olderThan, insertedBefore, entryKeywords);
} else if (STARRED.equals(req.getId())) {
feedEntryService.markStarredEntries(user, olderThan, insertedBefore);
@@ -260,6 +271,17 @@ public class CategoryREST {
return Response.ok().build();
}
private void removeFrequentSubscriptions(List<FeedSubscription> subs, User user) {
UserSettings userSettings = userSettingsDAO.findByUser(user);
int infrequentDays = userSettings != null && userSettings.getInfrequentThresholdDays() > 0
? userSettings.getInfrequentThresholdDays()
: 7;
long infrequentThresholdMs = (long) infrequentDays * 24 * 3600 * 1000;
subs.removeIf(
sub -> sub.getFeed().getAverageEntryInterval() == null || sub.getFeed().getAverageEntryInterval() < infrequentThresholdMs);
}
private void removeExcludedSubscriptions(List<FeedSubscription> subs, List<Long> excludedIds) {
if (CollectionUtils.isNotEmpty(excludedIds)) {
subs.removeIf(sub -> excludedIds.contains(sub.getId()));

View File

@@ -132,7 +132,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
if (settings.getPushNotifications() != null) {
s.getPushNotificationSettings().setType(settings.getPushNotifications().getType());
@@ -168,6 +170,8 @@ public class UserREST {
s.setUnreadCountTitle(false);
s.setUnreadCountFavicon(true);
s.setDisablePullToRefresh(false);
s.setDisableMobileSwipe(false);
s.setInfrequentThresholdDays(7);
}
return s;
}
@@ -204,7 +208,9 @@ public class UserREST {
s.setUnreadCountTitle(settings.isUnreadCountTitle());
s.setUnreadCountFavicon(settings.isUnreadCountFavicon());
s.setDisablePullToRefresh(settings.isDisablePullToRefresh());
s.setDisableMobileSwipe(settings.isDisableMobileSwipe());
s.setPrimaryColor(settings.getPrimaryColor());
s.setInfrequentThresholdDays(settings.getInfrequentThresholdDays());
PushNotificationUserSettings ps = new PushNotificationUserSettings();
ps.setType(settings.getPushNotificationSettings().getType());

View File

@@ -59,6 +59,7 @@ quarkus.native.additional-build-args=-H:PageSize=65536
# test profile overrides
%test.quarkus.log.category."org.mockserver".level=WARN
%test.quarkus.log.category."liquibase".level=WARN
%test.commafeed.shutdown-timeout=100ms
%test.commafeed.users.create-demo-account=true
%test.commafeed.users.allow-registrations=true
%test.commafeed.password-recovery-enabled=true

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="add-infrequent-days-threshold" author="athou">
<addColumn tableName="USERSETTINGS">
<column name="infrequentThresholdDays" type="INT" valueNumeric="7">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
<changeSet id="add-disable-mobile-swipe" author="athou">
<addColumn tableName="USERSETTINGS">
<column name="disableMobileSwipe" type="BOOLEAN" valueBoolean="false">
<constraints nullable="false" />
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View File

@@ -38,5 +38,6 @@
<include file="changelogs/db.changelog-5.11.xml" />
<include file="changelogs/db.changelog-5.12.xml" />
<include file="changelogs/db.changelog-7.0.xml" />
<include file="changelogs/db.changelog-gmfork.xml" />
</databaseChangeLog>

View File

@@ -6,6 +6,8 @@ import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.kohsuke.MetaInfServices;
import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.test.junit.callback.QuarkusTestBeforeEachCallback;
import io.quarkus.test.junit.callback.QuarkusTestMethodContext;
@@ -17,12 +19,17 @@ public class DatabaseReset implements QuarkusTestBeforeEachCallback {
@Override
public void beforeEach(QuarkusTestMethodContext context) {
CDI.current()
.select(EntityManager.class)
.get()
.unwrap(Session.class)
.getSessionFactory()
.getSchemaManager()
.truncateMappedObjects();
// stop the application to make sure that there are no active transactions when we truncate the tables
getBean(CommaFeedApplication.class).stop(new ShutdownEvent());
// truncate all tables so that we have a clean slate for the next test
getBean(EntityManager.class).unwrap(Session.class).getSessionFactory().getSchemaManager().truncateMappedObjects();
// restart the application
getBean(CommaFeedApplication.class).start(new StartupEvent());
}
private static <T> T getBean(Class<T> clazz) {
return CDI.current().select(clazz).get();
}
}

34
gmfork-build-docker.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
DB_VARIANT="${DB_VARIANT:-postgresql}"
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
ARTIFACTS_DIR="$REPO_ROOT/artifacts"
if [ -z "${DOCKER_REGISTRY:-}" ]; then
echo "Error: DOCKER_REGISTRY is not set" >&2
exit 1
fi
# Build
cd "$REPO_ROOT"
./mvnw --batch-mode --no-transfer-progress install -P${DB_VARIANT} -DskipTests
# Prepare artifacts
rm -rf "$ARTIFACTS_DIR"
mkdir -p "$ARTIFACTS_DIR"
cp commafeed-server/target/commafeed-*-${DB_VARIANT}-jvm.zip "$ARTIFACTS_DIR/"
unzip -q "$ARTIFACTS_DIR"/*-${DB_VARIANT}-jvm.zip -d "$ARTIFACTS_DIR/extracted-jvm-package"
mv "$ARTIFACTS_DIR/extracted-jvm-package"/commafeed-* "$ARTIFACTS_DIR/extracted-jvm-package/quarkus-app"
# Build image
docker build \
--platform linux/amd64 \
--file commafeed-server/src/main/docker/Dockerfile.jvm \
--tag "$DOCKER_REGISTRY/commafeed-fork:latest" \
.
rm -rf "$ARTIFACTS_DIR"
echo "Built: $DOCKER_REGISTRY/commafeed-fork:latest"