Compare commits

...

696 Commits
4.3.2 ... 5.3.1

Author SHA1 Message Date
Athou
74bce1308c release 5.3.1 2024-10-04 20:33:56 +02:00
Athou
98cfa6d2c8 add regression test 2024-10-04 20:24:43 +02:00
Athou
99a02a2186 fix issue with some HTTP feeds (#1572) 2024-10-04 20:20:02 +02:00
Jérémie Panzer
3431a813b1 Merge pull request #1574 from Athou/renovate/npm-10.x
chore(deps): update dependency npm to v10.9.0
2024-10-04 07:51:15 +02:00
renovate[bot]
e9e0e8d32b chore(deps): update dependency npm to v10.9.0 2024-10-03 19:31:06 +00:00
renovate[bot]
2d14409d35 fix(deps): update dependency io.dropwizard.metrics:metrics-json to v4.2.28 2024-10-03 19:31:04 +00:00
Jérémie Panzer
a8200e5c58 Merge pull request #1573 from Athou/renovate/node-20.x
chore(deps): update dependency node to v20.18.0
2024-10-03 21:30:44 +02:00
renovate[bot]
79a8df8b06 chore(deps): update dependency node to v20.18.0 2024-10-03 19:00:34 +00:00
renovate[bot]
061a5f0262 fix(deps): update mantine monorepo to ^7.13.2 2024-10-03 13:32:54 +00:00
renovate[bot]
821bdb3b0f chore(deps): update dependency vitest to ^2.1.2 2024-10-02 21:46:25 +00:00
renovate[bot]
606dfa9299 chore(deps): update dependency @types/react to ^18.3.11 2024-10-02 18:42:46 +00:00
renovate[bot]
131357c616 fix(deps): update swagger.version to v2.2.25 2024-10-02 13:17:53 +00:00
renovate[bot]
f6d3493bad chore(deps): update dependency @biomejs/biome to v1.9.3 2024-10-01 14:28:45 +00:00
renovate[bot]
0c6104e25b fix(deps): update mantine monorepo to ^7.13.1 2024-09-30 09:40:08 +00:00
Jérémie Panzer
d73735a35d Merge pull request #1571 from Athou/renovate/vitejs-plugin-react-4.3.x
chore(deps): update dependency @vitejs/plugin-react to ^4.3.2
2024-09-30 06:31:07 +02:00
renovate[bot]
e725d2d6b6 chore(deps): update dependency @vitejs/plugin-react to ^4.3.2 2024-09-30 04:02:46 +00:00
renovate[bot]
f0e1279d68 chore(deps): lock file maintenance 2024-09-30 00:36:34 +00:00
renovate[bot]
c74c74d2c4 chore(deps): update dependency com.puppycrawl.tools:checkstyle to v10.18.2 2024-09-29 19:11:52 +00:00
renovate[bot]
aa70cf5dcd chore(deps): update dependency @types/react to ^18.3.10 2024-09-27 16:41:55 +00:00
Jérémie Panzer
1055259627 Merge pull request #1569 from canoine/patch-1
Update fr/messages.po
2024-09-26 22:35:33 +02:00
canoine
302d37b6ef Update fr/messages.po
French translation update
2024-09-26 21:41:37 +02:00
Jérémie Panzer
8532a73d94 Merge pull request #1565 from Athou/renovate/quarkus.version
chore(deps): update quarkus.version to v3.15.1 (minor)
2024-09-26 10:25:09 +02:00
Athou
ffafb272cb update docs 2024-09-26 10:08:52 +02:00
Jérémie Panzer
22e0171a34 Merge pull request #1566 from dai/master
chore(ja): translated some strings
2024-09-26 10:03:32 +02:00
renovate[bot]
2b410f040c Update quarkus.version to v3.15.1 2024-09-26 08:02:28 +00:00
dai
259e8ad4e5 chore: translated some strings
Chore: Translated some new strings and reworked some wording.
2024-09-26 14:25:19 +09:00
Jérémie Panzer
21244dd9f5 Merge pull request #1564 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.13.0 (minor)
2024-09-25 12:52:53 +02:00
renovate[bot]
bc6206180d Update mantine monorepo to ^7.13.0 2024-09-25 09:53:00 +00:00
renovate[bot]
6e22d21358 Update dependency vite to ^5.4.8 2024-09-25 05:04:17 +00:00
renovate[bot]
95bdb4e700 Update dependency @types/react to ^18.3.9 2024-09-24 15:24:13 +00:00
Athou
9b7dbc68ab release 5.3.0 2024-09-24 07:58:43 +02:00
Athou
dc86c9b0db also manually load more entries if needed when pressing the next entry button in the header (#1557) 2024-09-24 07:54:50 +02:00
renovate[bot]
cb92ed753f Update swagger.version to v2.2.24 2024-09-23 16:36:50 +00:00
renovate[bot]
10a085e24e Lock file maintenance 2024-09-23 00:08:26 +00:00
renovate[bot]
3400a39edf Update dependency jsdom to ^25.0.1 2024-09-22 07:08:58 +00:00
Athou
21efffa345 Update dependency io.github.hakky54:sslcontext-kickstart-for-apache5 to v8.3.7
Update dependency org.apache.httpcomponents.client5:httpclient5 to v5.4
2024-09-22 09:07:20 +02:00
renovate[bot]
e2e80ba7e5 Update dependency com.github.eirslett:frontend-maven-plugin to v1.15.1 2024-09-21 18:11:14 +00:00
Jérémie Panzer
d988dba66e Merge pull request #1563 from Athou/renovate/querydsl.version
Update querydsl.version to v6.8 (minor)
2024-09-21 15:42:21 +02:00
renovate[bot]
403201fbff Update querydsl.version to v6.8 2024-09-21 13:25:28 +00:00
Athou
3cc93b51bb set default cooldown duration to 0 so it's not a breaking change 2024-09-21 10:57:16 +02:00
Athou
6a7d83bb45 show an error if force fetching feeds is not yet available 2024-09-21 10:31:17 +02:00
Athou
19c8db8b31 add a cooldown on the force refresh action (#1556) 2024-09-21 08:24:14 +02:00
renovate[bot]
0d75688ec8 Update dependency vite to ^5.4.7 2024-09-20 16:39:11 +00:00
renovate[bot]
e01dcb2f5b Update dependency @types/react to ^18.3.8 2024-09-19 22:25:45 +00:00
Jérémie Panzer
57757e2c14 Merge pull request #1561 from Athou/renovate/monaco-editor-0.x
Update dependency monaco-editor to ^0.52.0
2024-09-19 17:59:58 +02:00
renovate[bot]
779cd2fcfe Update dependency monaco-editor to ^0.52.0 2024-09-19 13:51:05 +00:00
renovate[bot]
94919f22e4 Update dependency @biomejs/biome to v1.9.2 2024-09-19 13:51:00 +00:00
Athou
d5cf690703 release 5.2.0 2024-09-18 17:21:10 +02:00
Athou
191574dace manually load more entries if needed when pressing a keyboard shortcut to go to the next entry (#1557) 2024-09-18 16:23:53 +02:00
renovate[bot]
ee7c6792c9 Update dependency @types/react to ^18.3.7 2024-09-17 11:39:38 +00:00
renovate[bot]
e2962dc2eb Update dependency vite to ^5.4.6 2024-09-16 23:19:58 +00:00
Jérémie Panzer
8c335cb8fd Merge pull request #1555 from victorhck/master
Update messages.po
2024-09-16 18:40:24 +02:00
Victorhck
461c18a591 Update messages.po
Keep in spanish the same name for Shift Key
2024-09-16 16:59:56 +02:00
Jérémie Panzer
363837ab26 we no longer need this file thanks to quarkus dev services 2024-09-16 15:55:06 +02:00
renovate[bot]
a184485421 Update dependency @types/react to ^18.3.6 2024-09-16 11:44:00 +00:00
renovate[bot]
f992c3f1a6 Lock file maintenance 2024-09-16 01:01:23 +00:00
Jérémie Panzer
3219a9e313 Merge pull request #1554 from Athou/renovate/biomejs-biome-1.9.x
Update dependency @biomejs/biome to v1.9.1
2024-09-15 21:39:44 +02:00
renovate[bot]
4717c31058 Update dependency @biomejs/biome to v1.9.1 2024-09-15 18:33:30 +00:00
Athou
693844828b comment tweak 2024-09-15 12:18:56 +02:00
renovate[bot]
ef4b479638 Update quarkus.version to v3.14.4 2024-09-14 16:38:57 +00:00
Athou
8eefb1bcfb add http cache to avoid fetching feeds too often (#1431) 2024-09-14 14:00:15 +02:00
Athou
ada9a5039b add support for all charsets in native mode 2024-09-14 07:56:36 +02:00
Athou
cca2d49cc3 Revert "reduce artifact size by using a smaller library for charset detection" because juniversalchardet doesn't support as many charsets as icu4j 2024-09-13 23:40:13 +02:00
Athou
f4a43e9950 only compute rtl once by storing it in the database on fetch 2024-09-13 22:25:02 +02:00
Athou
9a89b39b62 fix formatting 2024-09-13 21:57:21 +02:00
Jérémie Panzer
2dba844b6c Merge pull request #1552 from victorhck/master
uptade and improve Spanish translation
2024-09-13 21:56:49 +02:00
renovate[bot]
3101dc91de Update dependency vitest to ^2.1.1 2024-09-13 15:51:45 +00:00
Victorhck
83cacd97f3 fix typo Spanish messages.po 2024-09-13 17:19:44 +02:00
Victorhck
aa179c21f8 uptade and improve Spanish translation 2024-09-13 17:17:35 +02:00
Athou
31cf4d8bb2 use our own bidi detector to drop 10Mb from gwt 2024-09-13 16:05:21 +02:00
Athou
19bcc2c0da reduce artifact size by using a smaller library for charset detection 2024-09-13 14:48:24 +02:00
renovate[bot]
ca803ff7ce Update dependency vite to ^5.4.5 2024-09-13 09:42:41 +00:00
Athou
0e26d975aa we don't need quarkus-extension-processor at runtime 2024-09-13 10:55:02 +02:00
Athou
86a3cb67f2 exclude unused database dependencies from final artifact 2024-09-13 10:20:19 +02:00
Jérémie Panzer
6297463445 Merge pull request #1551 from Athou/renovate/com.microsoft.playwright-playwright-1.x
Update dependency com.microsoft.playwright:playwright to v1.47.0
2024-09-13 03:56:06 +02:00
renovate[bot]
1a3a3076b1 Update dependency com.microsoft.playwright:playwright to v1.47.0 2024-09-13 00:06:46 +00:00
Jérémie Panzer
7fb9cfeaf1 Merge pull request #1550 from Athou/renovate/vitest-monorepo
Update dependency vitest to ^2.1.0
2024-09-12 20:46:39 +02:00
renovate[bot]
5c7dbe6304 Update dependency vitest to ^2.1.0 2024-09-12 16:01:52 +00:00
Jérémie Panzer
c41fd9216a Merge pull request #1549 from Athou/renovate/fontsource-monorepo
Update dependency @fontsource/open-sans to ^5.1.0
2024-09-12 16:42:21 +02:00
Jérémie Panzer
91a9ad79f0 Merge pull request #1548 from Athou/renovate/biomejs-biome-1.x
Update dependency @biomejs/biome to v1.9.0
2024-09-12 16:42:10 +02:00
renovate[bot]
906458dc96 Update dependency @fontsource/open-sans to ^5.1.0 2024-09-12 14:21:15 +00:00
renovate[bot]
2f4fddf539 Update dependency @biomejs/biome to v1.9.0 2024-09-12 14:21:09 +00:00
renovate[bot]
a8157143b9 Update dependency io.quarkus.platform:quarkus-bom to v3.14.3 2024-09-11 14:55:31 +00:00
Athou
92576e28e9 fix tests incorrectly always running with h2 2024-09-11 16:49:02 +02:00
Athou
a6e34a2960 no need to repeat the plugin, we can just use the variable 2024-09-11 16:35:08 +02:00
renovate[bot]
306cf7aab7 Update dependency vite to ^5.4.4 2024-09-11 12:55:32 +00:00
Athou
f3b806686d help during development by showing typescript errors for the whole project 2024-09-11 14:54:01 +02:00
Athou
6634cfb991 load from old settings if new settings are not found to smooth transition 2024-09-11 07:52:41 +02:00
Athou
9930bb68b2 rename variable because it now contains a duration 2024-09-10 20:47:08 +02:00
Athou
37722131e5 remove warning about deprecated findDOMNode 2024-09-10 20:16:24 +02:00
Athou
5f2d213419 move other settings to localSettings too 2024-09-10 20:16:24 +02:00
Athou
e119941762 add option to keep some entries above the selected entry when scrolling 2024-09-10 20:16:24 +02:00
Athou
ba496c1395 no longer fetch feeds without subscriptions 2024-09-10 11:16:25 +02:00
renovate[bot]
9c3fc84542 Update dependency react-router-dom to ^6.26.2 2024-09-09 17:31:13 +00:00
Jérémie Panzer
b017ce936a Merge pull request #1546 from Athou/renovate/typescript-5.x
Update dependency typescript to ^5.6.2
2024-09-09 19:30:40 +02:00
renovate[bot]
d696b0581b Update dependency typescript to ^5.6.2 2024-09-09 17:03:23 +00:00
Athou
e4b2880f2b funding links tweaks 2024-09-09 18:56:04 +02:00
renovate[bot]
e071049969 Lock file maintenance 2024-09-09 03:25:09 +00:00
renovate[bot]
5e1f592951 Lock file maintenance 2024-09-09 00:21:56 +00:00
renovate[bot]
231f82da28 Update dependency @fontsource/open-sans to ^5.0.30 2024-09-08 04:53:38 +00:00
Athou
9a28bc7334 release 5.1.1 2024-09-06 11:44:37 +02:00
Athou
00907e92ff indicate that we actually set a boolean value (#1544) 2024-09-06 10:12:05 +02:00
Athou
5669798881 tweak bug report template 2024-09-06 09:09:14 +02:00
Athou
f3a62a5f75 always show current feed/category (#1543) 2024-09-06 09:09:14 +02:00
Jérémie Panzer
3b20ed222c Merge pull request #1542 from Athou/renovate/debian-12.x
Update debian Docker tag to v12.7
2024-09-05 04:42:59 +02:00
renovate[bot]
1f40f3f59c Update debian Docker tag to v12.7 2024-09-05 02:04:26 +00:00
Athou
a8d890524f fix doc 2024-09-04 20:22:24 +02:00
renovate[bot]
b635799e80 Update quarkus.version to v3.14.2 2024-09-04 17:32:46 +00:00
renovate[bot]
50ea66620d Update dependency tss-react to ^4.9.13 2024-09-04 07:10:20 +00:00
Athou
46581d0e22 quarkus has an extension that pulls both junit and mockito 2024-09-04 09:09:28 +02:00
renovate[bot]
a3562867a6 Update dependency vite to ^5.4.3 2024-09-03 19:41:15 +00:00
Athou
c0117ada93 add a link to the feed of the GitHub release page in the README 2024-09-03 19:38:55 +02:00
Jérémie Panzer
a3dcb2c03e Merge pull request #1540 from dai/master
Update messages.po
2024-09-03 10:45:13 +02:00
dai
8f8aaa5a1d Update messages.po
Updated and added Japanese language file.
2024-09-03 16:31:15 +09:00
renovate[bot]
85482422fd Lock file maintenance 2024-09-02 15:50:13 +00:00
Athou
643c969faf release 5.1.0 2024-09-02 17:47:08 +02:00
renovate[bot]
85f9469d6d Update linguijs monorepo to ^4.11.4 2024-09-02 14:07:26 +00:00
renovate[bot]
0df0d50695 Lock file maintenance 2024-09-02 01:33:37 +00:00
Athou
5e9256c197 reduce intermediate database cleanup logging level to reduce logging volume 2024-09-01 21:51:07 +02:00
Athou
b67e1a92f5 fix hibernate warnings about wrong types 2024-09-01 18:21:26 +02:00
Athou
d250e4bc26 add missing foreign key on feedentrystatuses.user_id 2024-09-01 18:02:38 +02:00
Athou
dcf1f41f2d add config doc sections 2024-09-01 13:47:09 +02:00
renovate[bot]
3df6ba1457 Update dependency axios to ^1.7.7 2024-08-31 22:33:06 +00:00
renovate[bot]
b89928f6c6 Update dependency axios to ^1.7.6 2024-08-30 21:06:14 +00:00
renovate[bot]
2e014484e3 Update mantine monorepo to ^7.12.2 2024-08-30 18:47:41 +00:00
renovate[bot]
3b2b18fd2e Update dependency com.puppycrawl.tools:checkstyle to v10.18.1 2024-08-30 15:35:27 +00:00
renovate[bot]
ebf1e592ff Update dependency @types/react to ^18.3.5 2024-08-30 10:40:03 +00:00
renovate[bot]
88404b91d8 Update dependency npm to v10.8.3 2024-08-28 20:30:31 +00:00
Athou
9cbb60313c there are only native implementations of brotli encoders, don't use them because it doesn't work on all platforms 2024-08-28 20:53:48 +02:00
Jérémie Panzer
b95d417f5e Merge pull request #1526 from Athou/renovate/quarkus.version
Update quarkus.version to v3.14.1 (minor)
2024-08-28 19:27:02 +02:00
Athou
994f1fb121 quarkus-config-doc-maven-plugin is now required to generate the documentation 2024-08-28 19:08:34 +02:00
renovate[bot]
e533c1fa4b Update quarkus.version to v3.14.1 2024-08-28 16:31:58 +00:00
renovate[bot]
d0d946ffe9 Update dependency vitest-mock-extended to ^2.0.2 2024-08-28 15:39:32 +00:00
Athou
e3bcc824c7 use a property to sync swagger versions 2024-08-28 17:36:15 +02:00
Athou
357e4e207f add a test to make sure brotli compression is supported 2024-08-28 17:34:24 +02:00
Athou
2aee961600 specify what version of checkstyle we want to use 2024-08-28 09:20:18 +02:00
Athou
3aa1987319 make renovate ignore all "io.quarkus" versions 2024-08-28 09:10:03 +02:00
Jérémie Panzer
ae15f61fc2 Merge pull request #1535 from Athou/renovate/org.apache.maven.plugins-maven-failsafe-plugin-3.x
Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.5.0
2024-08-27 16:55:50 +02:00
Jérémie Panzer
e58f92a812 Merge pull request #1536 from Athou/renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x
Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.5.0
2024-08-27 16:55:38 +02:00
renovate[bot]
46383924b1 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.5.0 2024-08-27 14:39:46 +00:00
renovate[bot]
071920e864 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.5.0 2024-08-27 14:39:42 +00:00
Athou
012238e6a9 Docker README tweak 2024-08-27 13:40:18 +02:00
Athou
a565566c50 remove deprecation warning 2024-08-27 09:02:42 +02:00
Athou
550804c666 use @Transactional where possible 2024-08-27 08:47:39 +02:00
Athou
f7a4a33f5e fix wrong path in documentation 2024-08-27 08:41:53 +02:00
Athou
f1b19ebae3 after reading the spec, what we want is actually "no-cache" that actually means "cache but revalidate immediately" using If-Modified-Since request headers and 304 response codes 2024-08-26 11:41:56 +02:00
Jérémie Panzer
4049fa2e17 Merge pull request #1534 from canoine/master
Update fr/messages.po
2024-08-26 09:43:21 +02:00
canoine
28808cf4f5 Merge pull request #2 from canoine/canoine-patch-1
Update fr/messages.po
2024-08-26 09:07:18 +02:00
canoine
870b46cf9d Update fr/messages.po
Blind update, as I didn't find where some of the new fields are shown.
2024-08-26 08:58:03 +02:00
renovate[bot]
9c20dea99c Lock file maintenance 2024-08-26 02:13:39 +00:00
Athou
63c7679067 make sure the webapp and openapi documentation are always up to date by preventing caching 2024-08-26 00:28:44 +02:00
Athou
764c1a6430 add setting for showing unread count in tab/favicon (#1518) 2024-08-25 20:24:57 +02:00
renovate[bot]
bb6578bdd0 Update dependency org.passay:passay to v1.6.5 2024-08-25 03:23:37 +00:00
renovate[bot]
748c8531ad Lock file maintenance 2024-08-23 19:32:29 +00:00
Athou
a734fe68d2 fix link README 2024-08-23 21:21:15 +02:00
renovate[bot]
cc5ebc55a4 Update dependency axios to ^1.7.5 2024-08-23 14:17:51 +00:00
Jérémie Panzer
aa396c1e1c Merge pull request #1531 from Athou/renovate/monaco-editor-0.x
Update dependency monaco-editor to ^0.51.0
2024-08-23 11:51:22 +02:00
Athou
fbf87ff291 make renovate ignore quarkus-extension-processor 2024-08-23 11:44:24 +02:00
renovate[bot]
e9f3ffddf4 Update dependency monaco-editor to ^0.51.0 2024-08-23 09:33:00 +00:00
Jérémie Panzer
695518d68b Merge pull request #1530 from Athou/renovate/querydsl.version
Update querydsl.version to v6.7 (minor)
2024-08-23 05:34:54 +02:00
renovate[bot]
5d96c1e12b Update querydsl.version to v6.7 2024-08-22 22:39:22 +00:00
Jérémie Panzer
3a72a1cc04 Merge pull request #1529 from Athou/renovate/org.apache.maven.plugins-maven-checkstyle-plugin-3.x
Update dependency org.apache.maven.plugins:maven-checkstyle-plugin to v3.5.0
2024-08-22 21:15:42 +02:00
renovate[bot]
54f5714108 Update dependency org.apache.maven.plugins:maven-checkstyle-plugin to v3.5.0 2024-08-22 18:58:15 +00:00
Jérémie Panzer
04811c7eca Merge pull request #1528 from Athou/renovate/org.apache.maven.plugins-maven-help-plugin-3.x
Update dependency org.apache.maven.plugins:maven-help-plugin to v3.5.0
2024-08-22 07:10:46 +02:00
renovate[bot]
6856736ddb Update dependency org.apache.maven.plugins:maven-help-plugin to v3.5.0 2024-08-21 21:04:31 +00:00
Athou
db6c43993a simpler workaround for cookie max-age 2024-08-21 21:56:57 +02:00
Jérémie Panzer
508a22576a Merge pull request #1527 from Athou/renovate/node-20.x
Update dependency node to v20.17.0
2024-08-21 21:35:26 +02:00
renovate[bot]
8fb012b3a1 Update dependency node to v20.17.0 2024-08-21 18:25:57 +00:00
renovate[bot]
133781d314 Update dependency @emotion/react to ^11.13.3 2024-08-21 11:41:31 +00:00
renovate[bot]
50cb12896e Update dependency vite to ^5.4.2 2024-08-21 02:14:50 +00:00
renovate[bot]
79a4315941 Update dependency @types/react to ^18.3.4 2024-08-20 22:13:31 +00:00
renovate[bot]
33a2f76521 Update dependency dayjs to ^1.11.13 2024-08-20 19:25:48 +00:00
Jérémie Panzer
d4041a1d88 Merge pull request #1525 from Athou/renovate/patch-quarkus.version
Update quarkus.version to v3.13.3 (patch)
2024-08-20 21:24:48 +02:00
renovate[bot]
09f2f56446 Update quarkus.version to v3.13.3 2024-08-20 17:16:54 +00:00
Athou
a0c3eda506 remove warning 2024-08-20 10:03:15 +02:00
Athou
84de3199cc guava version is managed by quarkus 2024-08-20 10:01:13 +02:00
Athou
a7e8309d63 fix docker readme missing word 2024-08-20 08:36:31 +02:00
Athou
7e74d2f6f4 release 5.0.2 2024-08-20 07:27:46 +02:00
Athou
dc25d53dc0 github actions is sometimes slow, increase timeout for tests 2024-08-20 00:03:45 +02:00
Athou
ac1a927836 remove google-api-services-youtube because it doesn't play nicely with native-image 2024-08-19 23:55:38 +02:00
Athou
b50b69adb2 Revert "better workaround for cookie max-age" because it causes issues with favicon fetching 2024-08-19 18:27:50 +02:00
Athou
b112e912af release 5.0.1 2024-08-19 15:55:13 +02:00
Athou
ece55727d3 better workaround for cookie max-age 2024-08-19 14:31:12 +02:00
Athou
181dd24b57 format both Dockerfiles the same way 2024-08-19 10:29:35 +02:00
Athou
10008ca0e8 add link to all quarkus settings 2024-08-19 09:38:26 +02:00
Athou
134c4621a8 docker README tweaks 2024-08-19 08:47:23 +02:00
Athou
51f15bf487 github actions is sometimes very slow, increase default timeouts for tests 2024-08-19 08:26:06 +02:00
renovate[bot]
49ae2c88ad Lock file maintenance 2024-08-19 05:53:45 +00:00
Jérémie Panzer
43a628fc55 Merge pull request #1523 from Athou/renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x
Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.4.0
2024-08-19 07:51:49 +02:00
Jérémie Panzer
7f71f95f7c Merge pull request #1522 from Athou/renovate/org.apache.maven.plugins-maven-failsafe-plugin-3.x
Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.4.0
2024-08-19 07:51:41 +02:00
Athou
e8d5eab419 compile to native image with support for older CPUs 2024-08-19 07:08:18 +02:00
renovate[bot]
de3a6b1f20 Update dependency io.dropwizard.metrics:metrics-json to v4.2.27 2024-08-19 00:25:33 +00:00
renovate[bot]
849742e19a Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.4.0 2024-08-18 21:04:13 +00:00
renovate[bot]
b6392b114c Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.4.0 2024-08-18 21:04:10 +00:00
Athou
4db0c775ff add settings documentation 2024-08-18 21:23:42 +02:00
Athou
ff9374f1ed README tweak 2024-08-18 19:20:34 +02:00
Athou
ea86c9bb1f README tweak 2024-08-18 17:07:31 +02:00
Athou
e6dd088abe fix typo 2024-08-18 17:00:59 +02:00
Jérémie Panzer
c039d8f3a4 Merge pull request #1521 from Athou/renovate/com.google.apis-google-api-services-youtube-3.0.x
Update dependency com.google.apis:google-api-services-youtube to v3-rev20240814-2.0.0
2024-08-18 14:51:39 +02:00
renovate[bot]
bffa6329fd Update dependency com.google.apis:google-api-services-youtube to v3-rev20240814-2.0.0 2024-08-18 10:58:38 +00:00
Athou
b88e5d2847 increase ssl handshake timeout during tests to fix build on slower machines 2024-08-18 12:25:52 +02:00
Jérémie Panzer
0fc4fcd406 Merge pull request #1516 from Athou/renovate/react-icons-5.x
Update dependency react-icons to ^5.3.0
2024-08-18 10:39:10 +02:00
Athou
f04ca21394 Twitter has been renamed X 2024-08-18 10:15:17 +02:00
renovate[bot]
a82fca130f Update dependency react-icons to ^5.3.0 2024-08-18 07:58:41 +00:00
Athou
70d494798c Docker readme tweaks 2024-08-18 09:45:44 +02:00
Athou
cf02bf221b release 5.0.0 2024-08-18 09:35:36 +02:00
renovate[bot]
9eb03d7455 Update quarkus.version to v3.13.2 2024-08-18 08:21:57 +02:00
renovate[bot]
12c8fdeec2 Update ibm-semeru-runtimes Docker tag to open-21.0.4_7-jre 2024-08-18 05:58:53 +00:00
Athou
851babfe2a remove quarkus branch from ci 2024-08-18 07:55:51 +02:00
Jérémie Panzer
859490806b Merge pull request #1520 from Athou/quarkus
Migrate to Quarkus (#1517)
2024-08-18 07:54:43 +02:00
renovate[bot]
2c828b50da Update dependency @fontsource/open-sans to ^5.0.29 2024-08-18 01:32:31 +00:00
Athou
ede7834cb8 configurable filtering expression evaluation timeout 2024-08-17 23:26:42 +02:00
Athou
3627ee369d version dockerhub readme and update it automatically on release 2024-08-17 23:16:19 +02:00
Athou
c4c41d1494 increase timeout a little bit because github actions are laggy 2024-08-17 22:29:40 +02:00
Athou
c577e77f8f README tweaks 2024-08-17 22:27:30 +02:00
Athou
9218f19832 javadoc tweaks 2024-08-17 22:11:47 +02:00
renovate[bot]
ecbc2133a4 Update dependency maven to v3.9.9 2024-08-17 20:10:04 +00:00
Athou
e38ca66c51 try to fix "Illegal attempt to associate a ManagedEntity with two open persistence contexts" 2024-08-17 16:22:46 +02:00
Athou
2395a2670e add ResourceBundle needed for cssparser in native image 2024-08-17 00:43:32 +02:00
Athou
e7748d787f no need to start a transaction to fetch favicons 2024-08-16 22:29:12 +02:00
Athou
012ce71134 configurable http client timeouts 2024-08-16 21:16:02 +02:00
Athou
1b1a3f49c1 keep using the same css parser as before 2024-08-16 19:01:44 +02:00
Athou
5b77860189 use join to speed up cleanup 2024-08-16 17:42:15 +02:00
Athou
b333e8d90a set a timeout on ssl handshakes 2024-08-16 14:40:16 +02:00
Athou
ab6457ef3f README tweaks 2024-08-16 14:23:18 +02:00
Athou
5c69daec08 restore welcome page on 401 2024-08-16 14:02:49 +02:00
Athou
1bfa3ebb8e set the default h2 path to a relative one next to the executable 2024-08-16 13:47:15 +02:00
Athou
2694fea211 cleanup 2024-08-16 08:45:30 +02:00
Athou
720eddeb66 CHANGELOG tweaks 2024-08-16 08:31:23 +02:00
Athou
ab334a7bc6 feed needs to be known to be deleted 2024-08-16 07:31:22 +02:00
Athou
214dfe580a more rome classes 2024-08-16 07:22:25 +02:00
Athou
4ef53eab3a mute rome modules warnings in production 2024-08-16 06:19:35 +02:00
Athou
2f51547f0d add missing rome classes 2024-08-16 06:15:52 +02:00
Athou
da910ac336 mute MediaModuleParser warnings 2024-08-16 05:53:23 +02:00
Athou
643954f7c9 timeout should be an Integer 2024-08-16 05:48:01 +02:00
renovate[bot]
63061482d0 Update dependency vite to ^5.4.1 2024-08-15 19:16:05 +00:00
renovate[bot]
86d4f5a670 Update dependency react-router-dom to ^6.26.1 2024-08-15 17:31:29 +00:00
Athou
815093f1c6 remove STARTUP_TIME because static fields are initialized at compile time in native mode 2024-08-15 12:17:28 +02:00
Athou
47d39831d3 use Duration for query timeout 2024-08-15 09:05:56 +02:00
Athou
c18ed829aa generate jvm package with a -jvm suffix 2024-08-14 23:08:24 +02:00
Athou
e757e61b79 config comment tweaks 2024-08-14 21:34:05 +02:00
Athou
d612d83874 resolve public url dynamically, remove publicUrl config element 2024-08-14 21:07:45 +02:00
Athou
e170dfe60b prepare 5.0.0 changelog 2024-08-14 20:43:54 +02:00
Athou
69cd90edd8 only use rest-assured for tests 2024-08-14 16:00:47 +02:00
renovate[bot]
f506f722c2 Update dependency axios to ^1.7.4 2024-08-13 20:16:28 +00:00
Athou
857736adad README tweaks 2024-08-13 17:26:51 +02:00
Athou
a92df774bd rome needs to clone Date in native mode 2024-08-13 16:13:05 +02:00
Athou
f2c6734c79 fix warning in native mode about parser not found 2024-08-13 16:12:40 +02:00
Athou
77b6cf75a5 README tweaks 2024-08-13 15:05:40 +02:00
Athou
3b56496196 javadoc tweaks 2024-08-13 12:49:04 +02:00
Athou
aabbf0a5d1 use a relative link 2024-08-13 12:49:04 +02:00
Athou
9a43fd434f ci for quarkus branch 2024-08-13 12:49:04 +02:00
Athou
21ce9db4b0 README update 2024-08-13 12:49:04 +02:00
Athou
044694487d remove redis as caching is no longer needed now 2024-08-13 12:49:04 +02:00
Athou
3af8485326 TODO remove redis 2024-08-13 12:49:04 +02:00
Athou
f7adef0648 add windows builds 2024-08-13 12:49:04 +02:00
Athou
dc16e43154 add release ci job 2024-08-13 12:49:04 +02:00
Athou
78a5267198 fix opml import encoding issue 2024-08-13 12:49:04 +02:00
Athou
04af355e0c remove unused timers page 2024-08-13 12:49:04 +02:00
Athou
89405009ec set a Max-Age on the auth cookie 2024-08-13 12:49:04 +02:00
Athou
6b0aa32da2 use profile instead of system property to set db-kind 2024-08-13 12:49:04 +02:00
Athou
aaf237d111 use quarkus mailer for password recovery 2024-08-13 12:49:04 +02:00
Athou
1fd48a0a40 merge docker amd64 and arm64 tags 2024-08-13 12:49:03 +02:00
Athou
09e0a51b46 restore Docker workflow 2024-08-13 12:49:00 +02:00
Athou
cc32f8ad16 WIP 2024-08-13 12:48:37 +02:00
renovate[bot]
2f6ddf0e70 Update mariadb Docker tag to v11.4.3 2024-08-13 00:48:48 +00:00
renovate[bot]
c3973755da Update ibm-semeru-runtimes Docker tag to open-21.0.4_7-jre 2024-08-12 23:34:26 +00:00
renovate[bot]
42537a65b9 Update dependency com.h2database:h2 to v2.3.232 2024-08-12 19:41:12 +00:00
Athou
906c92e54f fix badge layout 2024-08-12 21:13:46 +02:00
renovate[bot]
cc69968d78 Update mantine monorepo to ^7.12.1 2024-08-12 14:48:18 +00:00
renovate[bot]
dcde2083ec Lock file maintenance 2024-08-12 01:26:56 +00:00
Jérémie Panzer
7469784059 Merge pull request #1514 from Athou/renovate/com.microsoft.playwright-playwright-1.x
Update dependency com.microsoft.playwright:playwright to v1.46.0
2024-08-10 12:14:13 +02:00
renovate[bot]
c13a693456 Update dependency com.microsoft.playwright:playwright to v1.46.0 2024-08-09 23:05:37 +00:00
Jérémie Panzer
e3c482d664 Merge pull request #1510 from Athou/renovate/vite-5.x
Update dependency vite to ^5.4.0
2024-08-09 16:48:43 +02:00
renovate[bot]
1fd33a5585 Update dependency vite to ^5.4.0 2024-08-09 14:34:49 +00:00
Jérémie Panzer
0742778e6a Merge pull request #1513 from Athou/renovate/patch-linguijs-monorepo
Update linguijs monorepo to ^4.11.3 (patch)
2024-08-09 16:33:03 +02:00
Jérémie Panzer
152479c888 Merge pull request #1511 from Athou/renovate/vite-tsconfig-paths-5.x
Update dependency vite-tsconfig-paths to v5
2024-08-09 16:32:23 +02:00
Jérémie Panzer
a297f8c0c8 Merge pull request #1512 from Athou/renovate/postgres-16.x
Update postgres Docker tag to v16.4
2024-08-09 16:31:59 +02:00
renovate[bot]
92aeee0572 Update linguijs monorepo to ^4.11.3 2024-08-09 11:41:52 +00:00
renovate[bot]
050756517e Update dependency vite-tsconfig-paths to v5 2024-08-08 22:34:31 +00:00
renovate[bot]
0bb46f291a Update postgres Docker tag to v16.4 2024-08-08 22:34:10 +00:00
Jérémie Panzer
1eecabf105 Merge pull request #1508 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.12.0 (minor)
2024-08-05 19:16:47 +02:00
renovate[bot]
da1bd8d32e Update mantine monorepo to ^7.12.0 2024-08-05 16:28:35 +00:00
Athou
124983a396 rename metrics a little 2024-08-05 09:05:01 +02:00
Athou
43613688da evict unused HTTP connections 2024-08-05 08:41:12 +02:00
Athou
43aa69cd18 don't crash vite on error in dev mode 2024-08-05 08:28:17 +02:00
Athou
780b7666c5 add metrics for HttpGetter connection pool 2024-08-05 08:28:17 +02:00
renovate[bot]
70b4534e14 Lock file maintenance 2024-08-05 01:13:51 +00:00
Athou
24666fd7fc migrate to lingui.config.ts to benefit from typing 2024-08-03 22:29:09 +02:00
Athou
de80aa6bb3 replace t` with msg` to fix labels not being translated correctly 2024-08-03 13:09:15 +02:00
Athou
6c7e2ea847 add missing translation key for the Cmd key on MacOS 2024-08-03 12:56:56 +02:00
Athou
6ea318acd3 remove right section as we don't show keyboard shortcuts anywhere else 2024-08-03 12:54:57 +02:00
Athou
2f4ee7cff8 add data attributes to tree elements (#1507) 2024-08-03 12:37:33 +02:00
Athou
9d9d758fa6 use article instead of div (#1507) 2024-08-03 11:34:36 +02:00
Athou
a071b7c265 add aria-label to action buttons (#1507) 2024-08-03 11:30:29 +02:00
Athou
3a57b68fa3 use a different icon for filtering unread entries and marking an entry as read (#1506) 2024-08-03 10:24:06 +02:00
Jérémie Panzer
f2f36baf1b Merge pull request #1505 from Athou/renovate/querydsl.version
Update querydsl.version to v6.6 (minor)
2024-08-02 19:28:04 +02:00
renovate[bot]
1aaf9e747a Update querydsl.version to v6.6 2024-08-02 17:00:53 +00:00
Athou
92611772a9 reduce bottom margin slightly to avoid having a scrollbar in the extension popup when there are no entries 2024-08-02 10:37:40 +02:00
renovate[bot]
fb159dc46b Update dependency axios to ^1.7.3 2024-08-01 16:19:28 +00:00
Jérémie Panzer
78ea0873f2 Merge pull request #1504 from Athou/renovate/react-router-monorepo
Update dependency react-router-dom to ^6.26.0
2024-08-01 18:18:45 +02:00
renovate[bot]
faabc01dbc Update dependency react-router-dom to ^6.26.0 2024-08-01 13:48:59 +00:00
renovate[bot]
5acfe9e92a Update dependency tss-react to ^4.9.12 2024-08-01 12:45:12 +00:00
renovate[bot]
4388a8b6ce Update testcontainers-java monorepo to v1.20.1 2024-07-31 16:31:47 +00:00
renovate[bot]
7414bd15b0 Update dependency vitest to ^2.0.5 2024-07-31 13:41:55 +00:00
Jérémie Panzer
52d6021f3c Merge pull request #1503 from Athou/renovate/redis-7.x
Update redis Docker tag to v7.4.0
2024-07-30 07:14:48 +02:00
renovate[bot]
f7acc27fcb Update redis Docker tag to v7.4.0 2024-07-30 04:46:37 +00:00
renovate[bot]
175a293327 Update dependency redis.clients:jedis to v5.1.4 2024-07-29 17:17:09 +00:00
renovate[bot]
21dd6519b0 Update bouncycastle.version to v1.78.1 2024-07-29 11:25:29 +00:00
Athou
72f55c34b7 add test to make sure HttpGetter supports compression 2024-07-29 13:20:18 +02:00
Athou
1b1d3c791b add test to make sure HttpGetter ignores invalid certificates 2024-07-29 10:39:35 +02:00
renovate[bot]
159c2c01a7 Lock file maintenance 2024-07-29 00:18:18 +00:00
Athou
272f5b42f9 simplify stackoverflow urls 2024-07-28 09:58:24 +02:00
renovate[bot]
2395d0782e Update dependency @reduxjs/toolkit to ^2.2.7 2024-07-27 18:19:48 +00:00
Athou
da81830e43 add a test case for feeds that do not return a content length 2024-07-27 01:01:11 +02:00
renovate[bot]
63a602cf8a Update dependency tss-react to ^4.9.11 2024-07-26 16:23:31 +00:00
renovate[bot]
0244b5c3e3 Update dependency vite to ^5.3.5 2024-07-25 12:40:59 +00:00
Jérémie Panzer
9592e86fa9 Merge pull request #1497 from Athou/renovate/node-20.x
Update dependency node to v20.16.0
2024-07-24 21:11:44 +02:00
renovate[bot]
e6840bb50c Update dependency node to v20.16.0 2024-07-24 16:31:02 +00:00
Jérémie Panzer
b6890378a1 Merge pull request #1496 from Athou/renovate/vitest-mock-extended-2.x
Update dependency vitest-mock-extended to v2
2024-07-23 21:56:08 +02:00
renovate[bot]
ba72ed0b93 Update dependency vitest-mock-extended to v2 2024-07-23 19:45:08 +00:00
renovate[bot]
e2fb576858 Update ibm-semeru-runtimes Docker tag to open-21.0.3_9-jre 2024-07-23 15:40:19 +00:00
Athou
608b099b4d make renovate pickup semeru 2024-07-23 17:39:37 +02:00
renovate[bot]
c2e0c81f7e Update mysql Docker tag to v9.0.1 2024-07-23 07:16:23 +00:00
renovate[bot]
7071d01a59 Update dependency typescript to ^5.5.4 2024-07-23 04:26:11 +00:00
renovate[bot]
30cd2b9b53 Update dependency com.microsoft.playwright:playwright to v1.45.1 2024-07-23 00:05:41 +00:00
Athou
abc498b09c semeru already defines a JAVA_TOOL_OPTIONS variable with a shared classes cache, we don't want to override it 2024-07-22 16:52:30 +02:00
renovate[bot]
31081e1089 Update dependency vitest to ^2.0.4 2024-07-22 09:53:53 +00:00
renovate[bot]
4a16b8d072 Lock file maintenance 2024-07-22 02:12:54 +00:00
Jérémie Panzer
9c04095292 Merge pull request #1492 from Athou/renovate/emotion-monorepo
Update dependency @emotion/react to ^11.13.0
2024-07-20 14:55:31 +02:00
renovate[bot]
643f98d59e Update dependency @emotion/react to ^11.13.0 2024-07-20 09:06:33 +00:00
Jérémie Panzer
f4da19183e Merge pull request #1491 from Athou/renovate/emotion-monorepo
Update dependency @emotion/react to ^11.12.0
2024-07-19 09:56:36 +02:00
renovate[bot]
de40f253b5 Update dependency @emotion/react to ^11.12.0 2024-07-19 07:28:55 +00:00
renovate[bot]
f6543e407a Update dependency dayjs to ^1.11.12 2024-07-18 13:26:56 +00:00
renovate[bot]
4d462a8e9e Update dependency react-router-dom to ^6.25.1 2024-07-17 22:23:50 +00:00
Jérémie Panzer
018ee1f3e6 Merge pull request #1490 from Athou/renovate/testcontainers-java-monorepo
Update testcontainers-java monorepo to v1.20.0 (minor)
2024-07-18 00:22:46 +02:00
renovate[bot]
752268fed1 Update testcontainers-java monorepo to v1.20.0 2024-07-17 22:10:29 +00:00
renovate[bot]
8fe9a6cc3a Update dependency org.mariadb.jdbc:mariadb-java-client to v3.4.1 2024-07-17 20:48:37 +00:00
Athou
b17a17ba10 don't parse feeds that are too large to prevent memory issues 2024-07-16 21:20:06 +02:00
renovate[bot]
b3545b60ea Update dependency vite to ^5.3.4 2024-07-16 13:43:12 +00:00
Jérémie Panzer
e6da3f693d Merge pull request #1488 from Athou/renovate/react-router-monorepo
Update dependency react-router-dom to ^6.25.0
2024-07-16 15:42:04 +02:00
renovate[bot]
4ab09da434 Update dependency react-router-dom to ^6.25.0 2024-07-16 13:28:04 +00:00
renovate[bot]
5e8daf29bf Update dependency vitest-mock-extended to ^1.3.2 2024-07-15 21:14:52 +00:00
Athou
024a1067bb update h2 2024-07-15 21:48:49 +02:00
renovate[bot]
c427da72b9 Update dependency vitest to ^2.0.3 2024-07-15 14:05:06 +00:00
Athou
346fb6b1ea release 4.6.0 2024-07-15 16:03:09 +02:00
Athou
1b658c76a3 show both read and unread entries when searching with keywords 2024-07-15 12:41:13 +02:00
Athou
1593ed62ba github actions is slow, increase timeout 2024-07-15 11:11:19 +02:00
Athou
085eddd4b0 fill the shared classes cache of openj9 even more 2024-07-15 10:57:26 +02:00
Jérémie Panzer
0db77ad2c0 Merge pull request #1487 from Athou/renovate/com.manticore-projects.tools-h2migrationtool-1.x
Update dependency com.manticore-projects.tools:h2migrationtool to v1.7
2024-07-15 10:41:13 +02:00
renovate[bot]
6f8bcb6c6a Update dependency com.manticore-projects.tools:h2migrationtool to v1.7 2024-07-15 07:43:51 +00:00
renovate[bot]
4196dee896 Lock file maintenance 2024-07-15 01:09:26 +00:00
Athou
6d49e0f0df build openj9 shared classes cache to improve startup time 2024-07-14 22:26:39 +02:00
Athou
d99f572989 move env variable definition before adding files in order to maximize layer reusability 2024-07-14 21:14:39 +02:00
Athou
fa197c33f1 rename field accordingly 2024-07-14 20:37:01 +02:00
Athou
1ce39a419e use "published" instead of "updated" (#1486) 2024-07-14 19:53:35 +02:00
Athou
f0e3ac8fcb README tweaks 2024-07-14 09:35:44 +02:00
renovate[bot]
30947cea05 Update mantine monorepo to ^7.11.2 2024-07-13 15:46:24 +00:00
Athou
9134f36d3b use openj9 as the Java runtime to reduce memory usage 2024-07-13 13:48:34 +02:00
Athou
dc526316a0 enable string deduplication to reduce memory usage 2024-07-13 10:25:32 +02:00
renovate[bot]
6593174668 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.1 2024-07-11 01:02:47 +00:00
renovate[bot]
0891c41abc Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.1 2024-07-10 22:28:34 +00:00
renovate[bot]
6ecb6254aa Update dependency npm to v10.8.2 2024-07-10 19:02:33 +00:00
renovate[bot]
84bd9eeeff Update dependency vitest to ^2.0.2 2024-07-10 16:49:16 +00:00
Jérémie Panzer
2549c4d47b Merge pull request #1485 from Athou/renovate/org.jsoup-jsoup-1.x
Update dependency org.jsoup:jsoup to v1.18.1
2024-07-10 13:16:58 +02:00
renovate[bot]
8750aa3dd6 Update dependency org.jsoup:jsoup to v1.18.1 2024-07-10 11:02:42 +00:00
Athou
262094a736 remove dangling comment 2024-07-10 08:55:45 +02:00
Jérémie Panzer
035201f917 Merge pull request #1483 from Athou/renovate/major-vitest-monorepo
Update dependency vitest to v2
2024-07-09 03:47:48 +02:00
Jérémie Panzer
ae9cbc5214 Merge pull request #1484 from Athou/renovate/node-20.15.x
Update dependency node to v20.15.1
2024-07-09 03:42:53 +02:00
Athou
78d5bf129a fix build 2024-07-09 03:42:38 +02:00
renovate[bot]
1f02ddd163 Update dependency node to v20.15.1 2024-07-08 20:17:38 +00:00
renovate[bot]
eff1e8cc7b Update dependency vitest to v2 2024-07-08 16:18:13 +00:00
Jérémie Panzer
dc8475b59a Merge pull request #1482 from Athou/renovate/lock-file-maintenance
Lock file maintenance
2024-07-08 07:05:27 +02:00
renovate[bot]
921968662d Lock file maintenance 2024-07-08 02:21:45 +00:00
Athou
4d83173dbd release 4.5.0 2024-07-06 21:29:21 +02:00
Athou
f13368cb96 remove unnecessary joins 2024-07-06 13:40:53 +02:00
Athou
ec7e97e1de abort current request if we're changing what we're going to display 2024-07-04 07:19:16 +02:00
Athou
d4c9bd1dd7 remove warnings 2024-07-03 20:11:51 +02:00
renovate[bot]
6bff657d4d Update linguijs monorepo to ^4.11.2 2024-07-03 17:13:52 +00:00
renovate[bot]
613d286be1 Update dependency react-router-dom to ^6.24.1 2024-07-03 16:05:19 +00:00
Athou
fd48108f8b don't rely on dates to know if an entry has been inserted in the database 2024-07-03 17:53:41 +02:00
Athou
c3cbd18df9 add debug logging 2024-07-03 17:40:23 +02:00
Athou
6685057dae notify over websocket after everything has been committed 2024-07-03 17:27:17 +02:00
Athou
0dec0e3788 fix a race condition where a feed could be refreshed before it was created 2024-07-03 14:21:40 +02:00
Athou
1a73dd4004 the feed refresh engine is now fast enough, it doesn't need workarounds anymore 2024-07-03 13:30:25 +02:00
Athou
eae80a6450 remove support for microsoft sqlserver, AFAIK nobody's using it and it's not covered with integration tests 2024-07-03 13:02:20 +02:00
Jérémie Panzer
21a32ce0eb Merge pull request #1479 from Athou/renovate/mysql-9.x
Update mysql Docker tag to v9
2024-07-03 11:35:35 +02:00
renovate[bot]
325533c5d9 Update mysql Docker tag to v9 2024-07-03 09:10:22 +00:00
renovate[bot]
7d819022f6 Update redis Docker tag to v7.2.5 2024-07-03 09:10:19 +00:00
Athou
dba944874b add redis integration test 2024-07-03 11:09:23 +02:00
Jérémie Panzer
ce9c12ec92 Merge pull request #1478 from Athou/renovate/postgres-16.x
Update postgres Docker tag to v16.3
2024-07-03 09:23:35 +02:00
Jérémie Panzer
22dfc5774f Merge pull request #1477 from Athou/renovate/mariadb-11.x
Update mariadb Docker tag to v11.4.2
2024-07-03 09:23:24 +02:00
renovate[bot]
d59091ab2b Update postgres Docker tag to v16.3 2024-07-03 07:08:14 +00:00
renovate[bot]
f69146a6bf Update mariadb Docker tag to v11.4.2 2024-07-03 07:08:11 +00:00
Athou
43cdf3db3b add integration tests for postgresql, mysql and mariadb using testcontainers 2024-07-03 09:02:18 +02:00
renovate[bot]
280a354228 Update dependency vite-plugin-biome to ^1.0.12 2024-07-03 06:39:02 +00:00
renovate[bot]
573b0431f9 Update dependency vite to ^5.3.3 2024-07-03 06:32:04 +00:00
renovate[bot]
9878b60e97 Update dependency io.github.git-commit-id:git-commit-id-maven-plugin to v9.0.1 2024-07-02 18:02:54 +00:00
renovate[bot]
964033c2a7 Update mantine monorepo to ^7.11.1 2024-07-02 14:12:43 +00:00
Jérémie Panzer
d2e45aca91 Merge pull request #1475 from Athou/renovate/com.mysql-mysql-connector-j-9.x
Update dependency com.mysql:mysql-connector-j to v9
2024-07-02 16:11:43 +02:00
renovate[bot]
daa99a2efc Update dependency com.mysql:mysql-connector-j to v9 2024-07-02 10:08:39 +00:00
Jérémie Panzer
e986e9999a Merge pull request #1473 from Athou/renovate/node-20.x
Update dependency node to v20.15.0
2024-07-02 09:19:55 +02:00
Jérémie Panzer
98d302cb94 Merge pull request #1474 from Athou/renovate/npm-10.x
Update dependency npm to v10.8.1
2024-07-02 09:19:44 +02:00
renovate[bot]
bf11c4a7e4 Update dependency npm to v10.8.1 2024-07-02 07:13:46 +00:00
renovate[bot]
e1cab952f8 Update dependency node to v20.15.0 2024-07-02 07:13:42 +00:00
Athou
bc28d4de27 renovate can now update node and npm 2024-07-02 09:12:42 +02:00
renovate[bot]
bb901564e3 Update dependency typescript to ^5.5.3 2024-07-01 22:21:16 +00:00
Athou
93acc9ded1 we don't need the user we already have the subscription 2024-07-01 13:55:50 +02:00
renovate[bot]
9b1c6a371e Lock file maintenance 2024-07-01 07:50:40 +00:00
Athou
82bf8cd807 fetch all tags at once 2024-07-01 08:52:05 +02:00
Athou
c2f2780c3f remove unused onlyIds parameter 2024-07-01 08:52:05 +02:00
Athou
08f71d1f6f implement faster querying by fetching directly what we need 2024-07-01 08:52:05 +02:00
Athou
f498088beb no need to insert statuses that will be collected during next cleanup 2024-06-30 21:56:42 +02:00
Athou
347b41cf35 fix exception when trying to mark starred entries as read 2024-06-30 16:50:37 +02:00
renovate[bot]
61ae90ad28 Update dependency @reduxjs/toolkit to ^2.2.6 2024-06-29 14:12:13 +00:00
Athou
9a42fbafb2 only automerge patch updates, keep creating pull requests for minor updates 2024-06-29 08:47:49 +02:00
Athou
938f9e9434 remove automergePr because we now specify automergeBranch 2024-06-28 07:27:58 +02:00
Jérémie Panzer
9004e453c2 Merge pull request #1470 from Athou/renovate/querydsl.version
Update querydsl.version to v6.5
2024-06-28 07:26:21 +02:00
renovate[bot]
7d33542691 Update querydsl.version to v6.5 2024-06-28 05:25:56 +00:00
Athou
c99348862c reduce renovate noise by automerging if tests pass 2024-06-28 07:25:10 +02:00
Jérémie Panzer
ac86db3966 Merge pull request #1463 from Athou/renovate/com.manticore-projects.tools-h2migrationtool-1.x
Update dependency com.manticore-projects.tools:h2migrationtool to v1.6
2024-06-28 07:21:29 +02:00
Athou
e368810731 adapt script to new H2MigrationTool file output name 2024-06-28 07:16:42 +02:00
renovate[bot]
edae2f5a61 Update dependency com.manticore-projects.tools:h2migrationtool to v1.6 2024-06-28 03:34:34 +00:00
renovate[bot]
ab17c6f44e Update dependency org.projectlombok:lombok to v1.18.34 2024-06-28 03:34:21 +00:00
renovate[bot]
59dbae4f66 Update dependency com.microsoft.playwright:playwright to v1.45.0 2024-06-28 01:23:51 +00:00
renovate[bot]
d7956292df Update dependency vite-plugin-biome to ^1.0.11 2024-06-27 21:19:36 +00:00
renovate[bot]
1075497559 Update dependency vite to ^5.3.2 2024-06-27 19:44:59 +00:00
renovate[bot]
2d99fa03d3 Update dependency @biomejs/biome to v1.8.3 2024-06-27 16:49:21 +00:00
Jérémie Panzer
72b64b6f0d Merge pull request #1465 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.11.0
2024-06-27 09:35:00 +02:00
Jérémie Panzer
a2096d3622 Merge pull request #1457 from Athou/renovate/monaco-editor-0.x
Update dependency monaco-editor to ^0.50.0
2024-06-27 09:34:52 +02:00
Jérémie Panzer
c81f9fb7b1 Merge pull request #1459 from Athou/renovate/throttle-debounce-5.x
Update dependency throttle-debounce to ^5.0.2
2024-06-27 09:34:44 +02:00
Jérémie Panzer
cc7e9e21fb Merge pull request #1461 from Athou/renovate/react-router-monorepo
Update dependency react-router-dom to ^6.24.0
2024-06-27 09:34:35 +02:00
Jérémie Panzer
803d537e51 Merge pull request #1456 from Athou/renovate/biomejs-biome-1.x
Update dependency @biomejs/biome to v1.8.2
2024-06-27 09:34:20 +02:00
Jérémie Panzer
9a83e5b6ef Merge pull request #1458 from Athou/renovate/typescript-5.x
Update dependency typescript to ^5.5.2
2024-06-27 09:34:08 +02:00
renovate[bot]
4323da9007 Update mantine monorepo to ^7.11.0 2024-06-27 07:28:56 +00:00
renovate[bot]
30b9b24be4 Update dependency typescript to ^5.5.2 2024-06-27 07:28:42 +00:00
renovate[bot]
b191b00003 Update dependency react-router-dom to ^6.24.0 2024-06-27 07:28:30 +00:00
renovate[bot]
7e5cdcba34 Update dependency monaco-editor to ^0.50.0 2024-06-27 07:28:16 +00:00
renovate[bot]
45b30ad333 Update dependency throttle-debounce to ^5.0.2 2024-06-27 07:27:58 +00:00
renovate[bot]
7ca087b0a6 Update dependency @biomejs/biome to v1.8.2 2024-06-27 07:27:47 +00:00
Jérémie Panzer
188e4594fd automerge small dependency updates if they pass tests 2024-06-27 09:26:48 +02:00
Jérémie Panzer
2da80ce7d8 Merge pull request #1453 from Athou/renovate/org.apache.maven.plugins-maven-jar-plugin-3.x
Update dependency org.apache.maven.plugins:maven-jar-plugin to v3.4.2
2024-06-20 14:09:39 +02:00
renovate[bot]
d5820f9aa5 Update dependency org.apache.maven.plugins:maven-jar-plugin to v3.4.2 2024-06-19 18:42:15 +00:00
Athou
b1a0aae0a5 treat javac warnings as errors 2024-06-19 19:41:09 +02:00
Athou
cdd4d4b063 change BaseIT test class so that authentication with the "admin" user is not the default 2024-06-19 19:41:02 +02:00
Jérémie Panzer
21f675e80b Merge pull request #1451 from Athou/renovate/querydsl.version
Update querydsl.version to v6.4
2024-06-19 18:53:35 +02:00
renovate[bot]
380724d73e Update querydsl.version to v6.4 2024-06-19 16:49:05 +00:00
Athou
2d26c5dee3 remove empty file 2024-06-18 20:40:36 +02:00
Jérémie Panzer
29bcc5ccf5 Merge pull request #1437 from Athou/renovate/maven-3.x
Update dependency maven to v3.9.8
2024-06-17 22:31:07 +02:00
Jérémie Panzer
91497ab45a Merge pull request #1450 from Athou/renovate/docker-build-push-action-6.x
Update docker/build-push-action action to v6
2024-06-17 22:30:49 +02:00
renovate[bot]
be77968570 Update docker/build-push-action action to v6 2024-06-17 10:38:49 +00:00
renovate[bot]
a42dacc48d Update dependency maven to v3.9.8 2024-06-17 10:38:45 +00:00
Athou
cd06055246 release 4.4.1 2024-06-15 14:52:26 +02:00
Jérémie Panzer
62c1f25ffc Merge pull request #1448 from Athou/renovate/vite-5.x
Update dependency vite to ^5.3.1
2024-06-15 14:42:42 +02:00
Jérémie Panzer
415dc15d6c Merge pull request #1449 from Athou/renovate/mantine-monorepo
Update mantine monorepo to ^7.10.2
2024-06-15 14:41:52 +02:00
renovate[bot]
1d0c87c679 Update mantine monorepo to ^7.10.2 2024-06-15 12:32:58 +00:00
renovate[bot]
e51c486a04 Update dependency vite to ^5.3.1 2024-06-15 12:32:47 +00:00
Athou
73808c1a70 make renovate also bump versions in package.json 2024-06-15 14:32:05 +02:00
Athou
fbcc2ecd0f add a little delay to simulate a network operation 2024-06-15 14:00:15 +02:00
Athou
3997606774 looks like sometimes the websocket connection is not established on github actions, refresh the tree with an interval smaller than the timeout of playwright 2024-06-15 08:46:55 +02:00
Jérémie Panzer
b988b599d5 Merge pull request #1446 from Athou/renovate/org.apache.maven.plugins-maven-failsafe-plugin-3.x
Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.0
2024-06-14 22:31:42 +02:00
Jérémie Panzer
3e2ff2959d Merge pull request #1447 from Athou/renovate/org.apache.maven.plugins-maven-surefire-plugin-3.x
Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.0
2024-06-14 22:31:31 +02:00
renovate[bot]
5714a63d27 Update dependency org.apache.maven.plugins:maven-surefire-plugin to v3.3.0 2024-06-14 19:55:02 +00:00
renovate[bot]
12b18d1e04 Update dependency org.apache.maven.plugins:maven-failsafe-plugin to v3.3.0 2024-06-14 19:54:59 +00:00
Athou
232141cb56 fix UnsupportedTemporalTypeException when tests fail 2024-06-14 21:50:50 +02:00
Jérémie Panzer
c4334e5e6e Merge pull request #1445 from Athou/renovate/vite-5.x-lockfile
Update dependency vite to v5.3.1
2024-06-14 13:00:43 +02:00
renovate[bot]
ddf78f880b Update dependency vite to v5.3.1 2024-06-14 09:59:39 +00:00
Athou
b3651f3fba upload playwright artifacts on test failure to help debug what went wrong 2024-06-14 07:59:26 +02:00
Jérémie Panzer
24943b868c Merge pull request #1442 from Athou/renovate/mantine-monorepo
Update mantine monorepo to v7.10.2
2024-06-13 23:51:58 +02:00
renovate[bot]
ef71a691ef Update mantine monorepo to v7.10.2 2024-06-13 21:47:30 +00:00
Jérémie Panzer
01593d94eb Merge pull request #1443 from Athou/renovate/vite-5.x-lockfile
Update dependency vite to v5.3.0
2024-06-13 23:45:28 +02:00
renovate[bot]
b793cc66d1 Update dependency vite to v5.3.0 2024-06-13 21:31:42 +00:00
Athou
3810dedf47 replace complex eslint config with biome 2024-06-13 23:28:45 +02:00
Athou
9115797dee try to fix renovatebot warning about not being able to update commafeed-client 2024-06-13 06:53:23 +02:00
Athou
232658b934 remove commons-io since we already have guava 2024-06-12 17:17:54 +02:00
Athou
f99fe57695 remove 32bit arm7 because its support was dropped from temurin 21 2024-06-12 16:40:08 +02:00
Athou
ec89d41112 update mvn wrapper 2024-06-12 16:36:57 +02:00
Jérémie Panzer
f6d26a77cc Merge pull request #1439 from Athou/renovate/eclipse-temurin-21.x
Update eclipse-temurin Docker tag to v21
2024-06-12 16:33:07 +02:00
renovate[bot]
860852cc12 Update eclipse-temurin Docker tag to v21 2024-06-12 14:28:49 +00:00
Athou
d06d76401c download maven-wrapper binaries if needed 2024-06-12 16:24:26 +02:00
Athou
f5b04a783e remove commons-codec since we already have guava 2024-06-12 16:18:52 +02:00
Athou
964e470951 manually bump dependencies left behind by dependabot 2024-06-12 15:45:58 +02:00
Jérémie Panzer
612f8722dd Merge pull request #1433 from Athou/renovate/prettier-3.x-lockfile
Update dependency prettier to v3.3.2
2024-06-12 15:33:22 +02:00
Jérémie Panzer
e118dc9b7f Merge pull request #1434 from Athou/renovate/eclipse-temurin-17.x
Update eclipse-temurin Docker tag to v17.0.11_9-jre
2024-06-12 15:32:49 +02:00
renovate[bot]
6e42cdaf2d Update eclipse-temurin Docker tag to v17.0.11_9-jre 2024-06-12 13:28:00 +00:00
renovate[bot]
5198792ca5 Update dependency prettier to v3.3.2 2024-06-12 13:27:56 +00:00
Athou
10a71213f3 use renovate instead of dependabot 2024-06-12 15:27:48 +02:00
Jérémie Panzer
a5d0979d9f Merge pull request #1432 from Athou/renovate/configure
Configure Renovate
2024-06-12 15:27:13 +02:00
renovate[bot]
d84225ab1c Add renovate.json 2024-06-12 13:23:16 +00:00
Athou
cd86947e64 keep pull to refresh for safari (#1168) 2024-06-12 13:12:33 +02:00
Athou
f6b3114a91 use react helmet to manipulate scripts and styles programatically 2024-06-12 13:03:19 +02:00
Athou
cd50b6b058 use new playwright locators 2024-06-12 11:39:51 +02:00
Athou
b0c7ef18db fix test race condition 2024-06-12 10:30:47 +02:00
Athou
24171faf86 fetchFeedInternal follows redirects, we don't need to call it twice (#1431) 2024-06-12 08:21:11 +02:00
Jérémie Panzer
941f14dd41 Merge pull request #1374 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-plugin-react-hooks-4.6.2
Bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 in /commafeed-client
2024-06-11 08:37:23 +02:00
Jérémie Panzer
d46ef787db Merge pull request #1404 from Athou/dependabot/npm_and_yarn/commafeed-client/vitest-1.6.0
Bump vitest from 1.5.0 to 1.6.0 in /commafeed-client
2024-06-11 08:37:10 +02:00
Jérémie Panzer
ec7447a38c Merge pull request #1411 from Athou/dependabot/npm_and_yarn/commafeed-client/react-router-dom-6.23.1
Bump react-router-dom from 6.22.3 to 6.23.1 in /commafeed-client
2024-06-11 08:33:22 +02:00
dependabot[bot]
2a3fc3ae15 Bump eslint-plugin-react-hooks from 4.6.0 to 4.6.2 in /commafeed-client
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.0 to 4.6.2.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 06:31:59 +00:00
dependabot[bot]
ef25582bcb Bump react-router-dom from 6.22.3 to 6.23.1 in /commafeed-client
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.22.3 to 6.23.1.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.23.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 06:29:29 +00:00
Jérémie Panzer
55bbb2542d Merge pull request #1402 from Athou/dependabot/npm_and_yarn/commafeed-client/axios-1.7.2
Bump axios from 1.6.8 to 1.7.2 in /commafeed-client
2024-06-11 08:28:51 +02:00
Jérémie Panzer
8e94ac74a8 Merge pull request #1401 from Athou/dependabot/npm_and_yarn/commafeed-client/react-redux-9.1.2
Bump react-redux from 9.1.0 to 9.1.2 in /commafeed-client
2024-06-11 08:28:42 +02:00
Jérémie Panzer
90ecb9253c Merge pull request #1416 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-plugin-react-7.34.2
Bump eslint-plugin-react from 7.34.1 to 7.34.2 in /commafeed-client
2024-06-11 08:28:26 +02:00
Jérémie Panzer
6721842d98 Merge pull request #1414 from Athou/dependabot/npm_and_yarn/commafeed-client/lingui-240f3c17ef
Bump the lingui group across 1 directory with 5 updates
2024-06-11 08:28:15 +02:00
Jérémie Panzer
8b487ec414 Merge pull request #1428 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-eslint/eslint-plugin-7.13.0
Bump @typescript-eslint/eslint-plugin from 7.6.0 to 7.13.0 in /commafeed-client
2024-06-11 08:28:03 +02:00
dependabot[bot]
d6382861c3 Bump the lingui group across 1 directory with 5 updates
Bumps the lingui group with 5 updates in the /commafeed-client directory:

| Package | From | To |
| --- | --- | --- |
| [@lingui/core](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/macro](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/react](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/cli](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |
| [@lingui/vite-plugin](https://github.com/lingui/js-lingui) | `4.10.0` | `4.11.1` |



Updates `@lingui/core` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/macro` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/react` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/cli` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

Updates `@lingui/vite-plugin` from 4.10.0 to 4.11.1
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.10.0...v4.11.1)

---
updated-dependencies:
- dependency-name: "@lingui/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/macro"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/vite-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:27:42 +00:00
dependabot[bot]
2cdea99a69 Bump axios from 1.6.8 to 1.7.2 in /commafeed-client
Bumps [axios](https://github.com/axios/axios) from 1.6.8 to 1.7.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.8...v1.7.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:26:35 +00:00
dependabot[bot]
b1ae1c8afd Bump react-redux from 9.1.0 to 9.1.2 in /commafeed-client
Bumps [react-redux](https://github.com/reduxjs/react-redux) from 9.1.0 to 9.1.2.
- [Release notes](https://github.com/reduxjs/react-redux/releases)
- [Changelog](https://github.com/reduxjs/react-redux/blob/master/CHANGELOG.md)
- [Commits](https://github.com/reduxjs/react-redux/compare/v9.1.0...v9.1.2)

---
updated-dependencies:
- dependency-name: react-redux
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:26:07 +00:00
Jérémie Panzer
c09cd0c717 Merge pull request #1379 from Athou/dependabot/npm_and_yarn/commafeed-client/dayjs-1.11.11
Bump dayjs from 1.11.10 to 1.11.11 in /commafeed-client
2024-06-11 07:26:00 +02:00
Jérémie Panzer
f50e0ae272 Merge pull request #1375 from Athou/dependabot/npm_and_yarn/commafeed-client/multi-ec30208de6
Bump react-dom and @types/react-dom in /commafeed-client
2024-06-11 07:25:51 +02:00
Jérémie Panzer
b99b91a2a8 Merge pull request #1384 from Athou/dependabot/npm_and_yarn/commafeed-client/tss-react-4.9.10
Bump tss-react from 4.9.6 to 4.9.10 in /commafeed-client
2024-06-11 07:25:28 +02:00
Jérémie Panzer
d9759de6f1 Merge pull request #1403 from Athou/dependabot/npm_and_yarn/commafeed-client/monaco-editor-0.49.0
Bump monaco-editor from 0.47.0 to 0.49.0 in /commafeed-client
2024-06-11 07:25:19 +02:00
dependabot[bot]
cf2b7f9e4f Bump eslint-plugin-react from 7.34.1 to 7.34.2 in /commafeed-client
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.34.1 to 7.34.2.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.1...v7.34.2)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:25:14 +00:00
Jérémie Panzer
ee880c06ed Merge pull request #1407 from Athou/dependabot/npm_and_yarn/commafeed-client/react-icons-5.2.1
Bump react-icons from 5.0.1 to 5.2.1 in /commafeed-client
2024-06-11 07:25:10 +02:00
Jérémie Panzer
bc2e13ef22 Merge pull request #1410 from Athou/dependabot/npm_and_yarn/commafeed-client/reduxjs/toolkit-2.2.5
Bump @reduxjs/toolkit from 2.2.3 to 2.2.5 in /commafeed-client
2024-06-11 07:25:01 +02:00
dependabot[bot]
39ecfe2782 Bump @typescript-eslint/eslint-plugin in /commafeed-client
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.6.0 to 7.13.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.13.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:24:54 +00:00
Jérémie Panzer
3295d82f69 Merge pull request #1371 from Athou/dependabot/npm_and_yarn/commafeed-client/fontsource/open-sans-5.0.28
Bump @fontsource/open-sans from 5.0.27 to 5.0.28 in /commafeed-client
2024-06-11 07:24:42 +02:00
dependabot[bot]
1cd27a59e2 Bump vitest from 1.5.0 to 1.6.0 in /commafeed-client
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v1.6.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:24:35 +00:00
Jérémie Panzer
e1602edff1 Merge pull request #1426 from Athou/dependabot/npm_and_yarn/commafeed-client/redoc-2.1.5
Bump redoc from 2.1.3 to 2.1.5 in /commafeed-client
2024-06-11 07:24:03 +02:00
Jérémie Panzer
ef8e61d6fc Merge pull request #1427 from Athou/dependabot/npm_and_yarn/commafeed-client/prettier-3.3.1
Bump prettier from 3.2.5 to 3.3.1 in /commafeed-client
2024-06-11 07:23:54 +02:00
Jérémie Panzer
0057030442 Merge pull request #1429 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-5.2.13
Bump vite from 5.2.8 to 5.2.13 in /commafeed-client
2024-06-11 07:23:33 +02:00
Jérémie Panzer
6fabe46d6e Merge pull request #1430 from Athou/dependabot/npm_and_yarn/commafeed-client/vitejs/plugin-react-4.3.1
Bump @vitejs/plugin-react from 4.2.1 to 4.3.1 in /commafeed-client
2024-06-11 07:23:21 +02:00
dependabot[bot]
37c58f2755 Bump monaco-editor from 0.47.0 to 0.49.0 in /commafeed-client
Bumps [monaco-editor](https://github.com/microsoft/monaco-editor) from 0.47.0 to 0.49.0.
- [Release notes](https://github.com/microsoft/monaco-editor/releases)
- [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/monaco-editor/compare/v0.47.0...v0.49.0)

---
updated-dependencies:
- dependency-name: monaco-editor
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:18:41 +00:00
dependabot[bot]
bb982c3caf Bump @reduxjs/toolkit from 2.2.3 to 2.2.5 in /commafeed-client
Bumps [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) from 2.2.3 to 2.2.5.
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.2.3...v2.2.5)

---
updated-dependencies:
- dependency-name: "@reduxjs/toolkit"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:18:39 +00:00
Jérémie Panzer
7e4c3737a8 Merge pull request #1415 from Athou/dependabot/npm_and_yarn/commafeed-client/mantine-1f5c67be2f
Bump the mantine group across 1 directory with 6 updates
2024-06-11 07:17:44 +02:00
dependabot[bot]
23596b5ac6 Bump @vitejs/plugin-react from 4.2.1 to 4.3.1 in /commafeed-client
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.2.1 to 4.3.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:52 +00:00
dependabot[bot]
2fdeb7acd8 Bump vite from 5.2.8 to 5.2.13 in /commafeed-client
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.8 to 5.2.13.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.2.13/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.13/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:29 +00:00
dependabot[bot]
c62cac478c Bump the mantine group across 1 directory with 6 updates
Bumps the mantine group with 6 updates in the /commafeed-client directory:

| Package | From | To |
| --- | --- | --- |
| [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) | `7.8.0` | `7.10.1` |
| [@mantine/form](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/form) | `7.8.0` | `7.10.1` |
| [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) | `7.8.0` | `7.10.1` |
| [@mantine/modals](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/modals) | `7.8.0` | `7.10.1` |
| [@mantine/notifications](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/notifications) | `7.8.0` | `7.10.1` |
| [@mantine/spotlight](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/spotlight) | `7.8.0` | `7.10.1` |



Updates `@mantine/core` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/core)

Updates `@mantine/form` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/form)

Updates `@mantine/hooks` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/hooks)

Updates `@mantine/modals` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/modals)

Updates `@mantine/notifications` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/notifications)

Updates `@mantine/spotlight` from 7.8.0 to 7.10.1
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.10.1/packages/@mantine/spotlight)

---
updated-dependencies:
- dependency-name: "@mantine/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/form"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/hooks"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/modals"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/notifications"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/spotlight"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:12:05 +00:00
dependabot[bot]
e9026e0371 Bump prettier from 3.2.5 to 3.3.1 in /commafeed-client
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:11:50 +00:00
dependabot[bot]
7446d906ae Bump redoc from 2.1.3 to 2.1.5 in /commafeed-client
Bumps [redoc](https://github.com/Redocly/redoc) from 2.1.3 to 2.1.5.
- [Release notes](https://github.com/Redocly/redoc/releases)
- [Changelog](https://github.com/Redocly/redoc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Redocly/redoc/compare/v2.1.3...v2.1.5)

---
updated-dependencies:
- dependency-name: redoc
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:11:42 +00:00
Jérémie Panzer
62ad09ac93 Merge pull request #1358 from Athou/dependabot/maven/com.ibm.icu-icu4j-75.1
Bump com.ibm.icu:icu4j from 74.2 to 75.1
2024-06-11 07:10:33 +02:00
Jérémie Panzer
01d1f920a8 Merge pull request #1391 from Athou/dependabot/maven/com.microsoft.playwright-playwright-1.44.0
Bump com.microsoft.playwright:playwright from 1.43.0 to 1.44.0
2024-06-11 07:10:25 +02:00
Jérémie Panzer
057810470c Merge pull request #1392 from Athou/dependabot/maven/io.swagger.core.v3-swagger-annotations-2.2.22
Bump io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22
2024-06-11 07:10:18 +02:00
Jérémie Panzer
5a6d6be8e5 Merge pull request #1394 from Athou/dependabot/maven/com.google.apis-google-api-services-youtube-v3-rev20240514-2.0.0
Bump com.google.apis:google-api-services-youtube from v3-rev20240310-2.0.0 to v3-rev20240514-2.0.0
2024-06-11 07:10:09 +02:00
Jérémie Panzer
c6c813a4ee Merge pull request #1395 from Athou/dependabot/maven/io.swagger.core.v3-swagger-maven-plugin-jakarta-2.2.22
Bump io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.21 to 2.2.22
2024-06-11 07:10:01 +02:00
Jérémie Panzer
ad5787a38b Merge pull request #1421 from Athou/dependabot/maven/io.github.hakky54-sslcontext-kickstart-for-apache5-8.3.6
Bump io.github.hakky54:sslcontext-kickstart-for-apache5 from 8.3.4 to 8.3.6
2024-06-11 07:09:53 +02:00
Jérémie Panzer
387ceabf30 Merge pull request #1418 from Athou/dependabot/maven/org.apache.maven.plugins-maven-shade-plugin-3.6.0
Bump org.apache.maven.plugins:maven-shade-plugin from 3.5.2 to 3.6.0
2024-06-11 07:09:32 +02:00
Jérémie Panzer
ffe6962c36 Merge pull request #1423 from Athou/dependabot/maven/io.github.git-commit-id-git-commit-id-maven-plugin-9.0.0
Bump io.github.git-commit-id:git-commit-id-maven-plugin from 8.0.2 to 9.0.0
2024-06-11 07:09:24 +02:00
Jérémie Panzer
6d599fc77d Merge pull request #1422 from Athou/dependabot/maven/org.apache.maven.plugins-maven-checkstyle-plugin-3.4.0
Bump org.apache.maven.plugins:maven-checkstyle-plugin from 3.3.1 to 3.4.0
2024-06-11 07:09:01 +02:00
Jérémie Panzer
9fcff1342c Merge pull request #1425 from Athou/dependabot/npm_and_yarn/commafeed-client/braces-3.0.3
Bump braces from 3.0.2 to 3.0.3 in /commafeed-client
2024-06-11 07:08:01 +02:00
dependabot[bot]
f7dbc2e9aa Bump braces from 3.0.2 to 3.0.3 in /commafeed-client
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-11 05:03:52 +00:00
Athou
468f2e4c76 remove warnings 2024-06-11 07:02:38 +02:00
Athou
883c9c79aa replace source and target with the new release setting 2024-06-10 12:20:26 +02:00
Athou
f171d05088 querydsl is no longer maintained, use an active fork 2024-06-10 12:17:36 +02:00
dependabot[bot]
f85745fe40 Bump io.github.git-commit-id:git-commit-id-maven-plugin
Bumps [io.github.git-commit-id:git-commit-id-maven-plugin](https://github.com/git-commit-id/git-commit-id-maven-plugin) from 8.0.2 to 9.0.0.
- [Release notes](https://github.com/git-commit-id/git-commit-id-maven-plugin/releases)
- [Commits](https://github.com/git-commit-id/git-commit-id-maven-plugin/compare/v8.0.2...v9.0.0)

---
updated-dependencies:
- dependency-name: io.github.git-commit-id:git-commit-id-maven-plugin
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 09:17:15 +00:00
Athou
5ad93bb3ba add more checkstyle rules 2024-06-10 07:40:35 +02:00
dependabot[bot]
d80ed9d4dd Bump org.apache.maven.plugins:maven-checkstyle-plugin
Bumps [org.apache.maven.plugins:maven-checkstyle-plugin](https://github.com/apache/maven-checkstyle-plugin) from 3.3.1 to 3.4.0.
- [Commits](https://github.com/apache/maven-checkstyle-plugin/compare/maven-checkstyle-plugin-3.3.1...maven-checkstyle-plugin-3.4.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-checkstyle-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-08 20:35:05 +00:00
dependabot[bot]
69b5f5418a Bump io.github.hakky54:sslcontext-kickstart-for-apache5
Bumps [io.github.hakky54:sslcontext-kickstart-for-apache5](https://github.com/Hakky54/sslcontext-kickstart) from 8.3.4 to 8.3.6.
- [Changelog](https://github.com/Hakky54/sslcontext-kickstart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Hakky54/sslcontext-kickstart/compare/v8.3.4...v8.3.6)

---
updated-dependencies:
- dependency-name: io.github.hakky54:sslcontext-kickstart-for-apache5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-08 20:35:01 +00:00
dependabot[bot]
06aa37659c Bump org.apache.maven.plugins:maven-shade-plugin from 3.5.2 to 3.6.0
Bumps [org.apache.maven.plugins:maven-shade-plugin](https://github.com/apache/maven-shade-plugin) from 3.5.2 to 3.6.0.
- [Release notes](https://github.com/apache/maven-shade-plugin/releases)
- [Commits](https://github.com/apache/maven-shade-plugin/compare/maven-shade-plugin-3.5.2...maven-shade-plugin-3.6.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-shade-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-03 09:45:39 +00:00
Jérémie Panzer
d5c98de839 Merge pull request #1360 from Athou/dependabot/maven/org.apache.maven.plugins-maven-jar-plugin-3.4.1
Bump org.apache.maven.plugins:maven-jar-plugin from 3.4.0 to 3.4.1
2024-06-02 10:19:14 +02:00
Jérémie Panzer
920975059c Merge pull request #1393 from Athou/dependabot/maven/org.mariadb.jdbc-mariadb-java-client-3.4.0
Bump org.mariadb.jdbc:mariadb-java-client from 3.3.3 to 3.4.0
2024-06-02 10:18:07 +02:00
Jérémie Panzer
7c6e4c3356 Merge pull request #1397 from Athou/dependabot/maven/redis.clients-jedis-5.1.3
Bump redis.clients:jedis from 5.1.2 to 5.1.3
2024-06-02 10:17:46 +02:00
Jérémie Panzer
143971da5e Merge pull request #1385 from Athou/dependabot/maven/com.mysql-mysql-connector-j-8.4.0
Bump com.mysql:mysql-connector-j from 8.3.0 to 8.4.0
2024-06-02 10:17:32 +02:00
dependabot[bot]
8976e9c01a Bump react-icons from 5.0.1 to 5.2.1 in /commafeed-client
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.0.1 to 5.2.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.0.1...v5.2.1)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 07:10:59 +00:00
dependabot[bot]
20c6355efd Bump redis.clients:jedis from 5.1.2 to 5.1.3
Bumps [redis.clients:jedis](https://github.com/redis/jedis) from 5.1.2 to 5.1.3.
- [Release notes](https://github.com/redis/jedis/releases)
- [Commits](https://github.com/redis/jedis/compare/v5.1.2...v5.1.3)

---
updated-dependencies:
- dependency-name: redis.clients:jedis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-27 07:06:21 +00:00
Jérémie Panzer
f86f38ef7a Merge pull request #1396 from SConaway/patch-1
login page: don't autocapitalize the username input
2024-05-26 10:14:36 +02:00
Jérémie Panzer
24311df551 Merge pull request #1389 from luckrnx09/luckrnx09-cmd-k-for-macos-users
Maint: Show `Cmd + K` for macOS users
2024-05-26 10:14:10 +02:00
luckrnx09
d02aa78def fix: determine os type from useOs hook 2024-05-25 21:35:10 +08:00
Steven Conaway
b131020f46 login page: don't autocapitalize the username input 2024-05-22 10:37:32 -06:00
dependabot[bot]
4ab82782b0 Bump io.swagger.core.v3:swagger-maven-plugin-jakarta
Bumps io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.21 to 2.2.22.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-maven-plugin-jakarta
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:56:18 +00:00
dependabot[bot]
6f9ebd5d78 Bump com.google.apis:google-api-services-youtube
Bumps com.google.apis:google-api-services-youtube from v3-rev20240310-2.0.0 to v3-rev20240514-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-youtube
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:56:15 +00:00
dependabot[bot]
7ebbf26369 Bump org.mariadb.jdbc:mariadb-java-client from 3.3.3 to 3.4.0
Bumps [org.mariadb.jdbc:mariadb-java-client](https://github.com/mariadb-corporation/mariadb-connector-j) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/mariadb-corporation/mariadb-connector-j/releases)
- [Changelog](https://github.com/mariadb-corporation/mariadb-connector-j/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mariadb-corporation/mariadb-connector-j/compare/3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: org.mariadb.jdbc:mariadb-java-client
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:10 +00:00
dependabot[bot]
dbc93f9928 Bump io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22
Bumps io.swagger.core.v3:swagger-annotations from 2.2.21 to 2.2.22.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-annotations
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:06 +00:00
dependabot[bot]
ad6ebd7e4d Bump com.microsoft.playwright:playwright from 1.43.0 to 1.44.0
Bumps [com.microsoft.playwright:playwright](https://github.com/microsoft/playwright-java) from 1.43.0 to 1.44.0.
- [Release notes](https://github.com/microsoft/playwright-java/releases)
- [Commits](https://github.com/microsoft/playwright-java/compare/v1.43.0...v1.44.0)

---
updated-dependencies:
- dependency-name: com.microsoft.playwright:playwright
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 09:55:04 +00:00
luckrnx09
ab86247c8c update the hot key to mod+k for call spotlight 2024-05-15 21:47:34 +08:00
luckrnx09
884516be28 Maint: Show Cmd + K for macOS users 2024-05-15 21:42:32 +08:00
dependabot[bot]
c236b1adda Bump com.mysql:mysql-connector-j from 8.3.0 to 8.4.0
Bumps [com.mysql:mysql-connector-j](https://github.com/mysql/mysql-connector-j) from 8.3.0 to 8.4.0.
- [Changelog](https://github.com/mysql/mysql-connector-j/blob/release/8.x/CHANGES)
- [Commits](https://github.com/mysql/mysql-connector-j/compare/8.3.0...8.4.0)

---
updated-dependencies:
- dependency-name: com.mysql:mysql-connector-j
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 09:42:14 +00:00
dependabot[bot]
222117dafe Bump tss-react from 4.9.6 to 4.9.10 in /commafeed-client
Bumps [tss-react](https://github.com/garronej/tss-react) from 4.9.6 to 4.9.10.
- [Release notes](https://github.com/garronej/tss-react/releases)
- [Commits](https://github.com/garronej/tss-react/compare/v4.9.6...v4.9.10)

---
updated-dependencies:
- dependency-name: tss-react
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 09:22:50 +00:00
dependabot[bot]
38cd27df57 Bump dayjs from 1.11.10 to 1.11.11 in /commafeed-client
Bumps [dayjs](https://github.com/iamkun/dayjs) from 1.11.10 to 1.11.11.
- [Release notes](https://github.com/iamkun/dayjs/releases)
- [Changelog](https://github.com/iamkun/dayjs/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/iamkun/dayjs/compare/v1.11.10...v1.11.11)

---
updated-dependencies:
- dependency-name: dayjs
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 15:58:08 +00:00
dependabot[bot]
5e07e74bb2 Bump react-dom and @types/react-dom in /commafeed-client
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together.

Updates `react-dom` from 18.2.0 to 18.3.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/react-dom)

Updates `@types/react-dom` from 18.2.25 to 18.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-26 19:04:55 +00:00
dependabot[bot]
fe779e361f Bump @fontsource/open-sans from 5.0.27 to 5.0.28 in /commafeed-client
Bumps [@fontsource/open-sans](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/open-sans) from 5.0.27 to 5.0.28.
- [Changelog](https://github.com/fontsource/font-files/blob/main/fonts/google/open-sans/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/open-sans)

---
updated-dependencies:
- dependency-name: "@fontsource/open-sans"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-26 19:03:47 +00:00
dependabot[bot]
1a51799497 Bump org.apache.maven.plugins:maven-jar-plugin from 3.4.0 to 3.4.1
Bumps [org.apache.maven.plugins:maven-jar-plugin](https://github.com/apache/maven-jar-plugin) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/apache/maven-jar-plugin/releases)
- [Commits](https://github.com/apache/maven-jar-plugin/compare/maven-jar-plugin-3.4.0...maven-jar-plugin-3.4.1)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-jar-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 09:47:58 +00:00
dependabot[bot]
6ea926cdb0 Bump com.ibm.icu:icu4j from 74.2 to 75.1
Bumps [com.ibm.icu:icu4j](https://github.com/unicode-org/icu) from 74.2 to 75.1.
- [Release notes](https://github.com/unicode-org/icu/releases)
- [Commits](https://github.com/unicode-org/icu/commits)

---
updated-dependencies:
- dependency-name: com.ibm.icu:icu4j
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 09:46:50 +00:00
Jérémie Panzer
439d61946a Merge pull request #1351 from WangLei1993/master
add Chinese translation for new entry
2024-04-16 10:20:52 +02:00
WangLei1993
426c8d7dfb add Chinese translation for new entry 2024-04-16 15:01:23 +08:00
Athou
f1b51e8342 set the default value for new users to the same value as the default value for existing users 2024-04-15 21:59:54 +02:00
Athou
9de19e9f2d release 4.4.0 2024-04-15 16:40:10 +02:00
Athou
fc2eac7f2c align star vertically with feed favicon 2024-04-14 19:46:27 +02:00
Athou
bc6fc01c3f set on_desktop as the default value for icon because we don't have a lot of room on mobile 2024-04-14 18:40:43 +02:00
Athou
85ae70f278 fix combobox labels not displayed correctly 2024-04-14 18:25:45 +02:00
Athou
e415d1d945 remove dependency on bouncycastle 2024-04-14 17:36:35 +02:00
Jérémie Panzer
acb06c3405 Merge pull request #1344 from Athou/dependabot/npm_and_yarn/commafeed-client/lingui-be06312aa6
Bump the lingui group in /commafeed-client with 5 updates
2024-04-14 17:34:04 +02:00
Jérémie Panzer
a137ecb293 Merge pull request #1343 from Athou/dependabot/npm_and_yarn/commafeed-client/vitest-1.5.0
Bump vitest from 1.3.1 to 1.5.0 in /commafeed-client
2024-04-14 17:33:57 +02:00
dependabot[bot]
b4c1aea7c4 Bump the lingui group in /commafeed-client with 5 updates
Bumps the lingui group in /commafeed-client with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lingui/core](https://github.com/lingui/js-lingui) | `4.7.1` | `4.10.0` |
| [@lingui/macro](https://github.com/lingui/js-lingui) | `4.7.1` | `4.10.0` |
| [@lingui/react](https://github.com/lingui/js-lingui) | `4.7.1` | `4.10.0` |
| [@lingui/cli](https://github.com/lingui/js-lingui) | `4.7.1` | `4.10.0` |
| [@lingui/vite-plugin](https://github.com/lingui/js-lingui) | `4.7.1` | `4.10.0` |


Updates `@lingui/core` from 4.7.1 to 4.10.0
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.7.1...v4.10.0)

Updates `@lingui/macro` from 4.7.1 to 4.10.0
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.7.1...v4.10.0)

Updates `@lingui/react` from 4.7.1 to 4.10.0
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.7.1...v4.10.0)

Updates `@lingui/cli` from 4.7.1 to 4.10.0
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.7.1...v4.10.0)

Updates `@lingui/vite-plugin` from 4.7.1 to 4.10.0
- [Release notes](https://github.com/lingui/js-lingui/releases)
- [Changelog](https://github.com/lingui/js-lingui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lingui/js-lingui/compare/v4.7.1...v4.10.0)

---
updated-dependencies:
- dependency-name: "@lingui/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/macro"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/react"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
- dependency-name: "@lingui/vite-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: lingui
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 15:22:41 +00:00
dependabot[bot]
a0d86ce94a Bump vitest from 1.3.1 to 1.5.0 in /commafeed-client
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 1.3.1 to 1.5.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v1.5.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 15:21:20 +00:00
Jérémie Panzer
6ce0e2f151 Merge pull request #1342 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-5.4.5
Bump typescript from 5.4.2 to 5.4.5 in /commafeed-client
2024-04-14 17:20:31 +02:00
dependabot[bot]
628f7aca90 Bump typescript from 5.4.2 to 5.4.5 in /commafeed-client
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.2 to 5.4.5.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.2...v5.4.5)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 15:20:26 +00:00
Jérémie Panzer
a4a7d53670 Merge pull request #1346 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-5.2.8
Bump vite from 5.2.7 to 5.2.8 in /commafeed-client
2024-04-14 17:19:40 +02:00
Jérémie Panzer
e76e7879cd Merge pull request #1348 from Athou/dependabot/npm_and_yarn/commafeed-client/types/react-dom-18.2.25
Bump @types/react-dom from 18.2.21 to 18.2.25 in /commafeed-client
2024-04-14 17:19:33 +02:00
Jérémie Panzer
7a00e743eb Merge pull request #1347 from Athou/dependabot/npm_and_yarn/commafeed-client/mantine-49597fe963
Bump the mantine group in /commafeed-client with 6 updates
2024-04-14 17:18:53 +02:00
Athou
6e0e692ae8 generate new translations 2024-04-14 17:16:32 +02:00
dependabot[bot]
321b3d4819 Bump the mantine group in /commafeed-client with 6 updates
Bumps the mantine group in /commafeed-client with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) | `7.6.1` | `7.8.0` |
| [@mantine/form](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/form) | `7.6.1` | `7.8.0` |
| [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) | `7.6.1` | `7.8.0` |
| [@mantine/modals](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/modals) | `7.6.1` | `7.8.0` |
| [@mantine/notifications](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/notifications) | `7.6.1` | `7.8.0` |
| [@mantine/spotlight](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/spotlight) | `7.6.1` | `7.8.0` |


Updates `@mantine/core` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/core)

Updates `@mantine/form` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/form)

Updates `@mantine/hooks` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/hooks)

Updates `@mantine/modals` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/modals)

Updates `@mantine/notifications` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/notifications)

Updates `@mantine/spotlight` from 7.6.1 to 7.8.0
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/7.8.0/packages/@mantine/spotlight)

---
updated-dependencies:
- dependency-name: "@mantine/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/form"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/hooks"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/modals"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/notifications"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
- dependency-name: "@mantine/spotlight"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mantine
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 15:12:55 +00:00
Athou
211708255e actually save new settings 2024-04-14 16:59:04 +02:00
Athou
dcc32cb539 use a random available port for tests 2024-04-14 16:49:26 +02:00
dependabot[bot]
c9367afd9d Bump vite from 5.2.7 to 5.2.8 in /commafeed-client
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.7 to 5.2.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 14:29:58 +00:00
dependabot[bot]
af724fbb87 Bump @types/react-dom from 18.2.21 to 18.2.25 in /commafeed-client
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 18.2.21 to 18.2.25.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 14:29:56 +00:00
Jérémie Panzer
9d052f2f59 Merge pull request #1349 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-eslint/eslint-plugin-7.6.0
Bump @typescript-eslint/eslint-plugin from 7.5.0 to 7.6.0 in /commafeed-client
2024-04-14 16:29:34 +02:00
Jérémie Panzer
e9a9334c03 Merge pull request #1345 from Athou/dependabot/npm_and_yarn/commafeed-client/types/react-18.2.78
Bump @types/react from 18.2.64 to 18.2.78 in /commafeed-client
2024-04-14 16:29:19 +02:00
Jérémie Panzer
80c9adcf0f Merge pull request #1319 from Athou/dependabot/npm_and_yarn/commafeed-client/reduxjs/toolkit-2.2.3
Bump @reduxjs/toolkit from 2.2.1 to 2.2.3 in /commafeed-client
2024-04-14 16:28:59 +02:00
Jérémie Panzer
13c402d9d0 Merge pull request #1320 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-tsconfig-paths-4.3.2
Bump vite-tsconfig-paths from 4.3.1 to 4.3.2 in /commafeed-client
2024-04-14 16:28:53 +02:00
Jérémie Panzer
5d5dc67a46 Merge pull request #1323 from Athou/dependabot/npm_and_yarn/commafeed-client/fontsource/open-sans-5.0.27
Bump @fontsource/open-sans from 5.0.26 to 5.0.27 in /commafeed-client
2024-04-14 16:28:45 +02:00
Jérémie Panzer
f2330d8346 Merge pull request #1328 from Athou/dependabot/npm_and_yarn/commafeed-client/tss-react-4.9.6
Bump tss-react from 4.9.4 to 4.9.6 in /commafeed-client
2024-04-14 16:28:38 +02:00
Jérémie Panzer
ee061f3362 Merge pull request #1350 from Athou/dependabot/maven/commons-io-commons-io-2.16.1
Bump commons-io:commons-io from 2.16.0 to 2.16.1
2024-04-14 16:27:45 +02:00
Jérémie Panzer
e071cb457f Merge pull request #1310 from Athou/dependabot/maven/io.swagger.core.v3-swagger-maven-plugin-jakarta-2.2.21
Bump io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.20 to 2.2.21
2024-04-14 16:27:33 +02:00
Jérémie Panzer
7972dec827 Merge pull request #1311 from Athou/dependabot/maven/io.github.git-commit-id-git-commit-id-maven-plugin-8.0.2
Bump io.github.git-commit-id:git-commit-id-maven-plugin from 8.0.1 to 8.0.2
2024-04-14 16:27:27 +02:00
Jérémie Panzer
41c0200270 Merge pull request #1308 from Athou/dependabot/maven/io.swagger.core.v3-swagger-annotations-2.2.21
Bump io.swagger.core.v3:swagger-annotations from 2.2.20 to 2.2.21
2024-04-14 16:27:18 +02:00
Jérémie Panzer
9e5fa5472a Merge pull request #1307 from Athou/dependabot/maven/io.github.hakky54-sslcontext-kickstart-for-apache5-8.3.4
Bump io.github.hakky54:sslcontext-kickstart-for-apache5 from 8.3.2 to 8.3.4
2024-04-14 16:27:10 +02:00
Jérémie Panzer
0ebab27588 Merge pull request #1297 from Athou/dependabot/maven/com.google.apis-google-api-services-youtube-v3-rev20240310-2.0.0
Bump com.google.apis:google-api-services-youtube from v3-rev20240303-2.0.0 to v3-rev20240310-2.0.0
2024-04-14 16:26:55 +02:00
Athou
0d081bc47e add button in the header to star entry (#1025) 2024-04-14 16:22:51 +02:00
dependabot[bot]
92853a164a Bump @typescript-eslint/eslint-plugin in /commafeed-client
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.5.0 to 7.6.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.6.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:16:34 +00:00
Jérémie Panzer
5d75885352 Merge pull request #1341 from Athou/dependabot/maven/com.microsoft.playwright-playwright-1.43.0
Bump com.microsoft.playwright:playwright from 1.41.2 to 1.43.0
2024-04-14 11:15:40 +02:00
Jérémie Panzer
83b8886846 Merge pull request #1340 from Athou/dependabot/maven/org.apache.maven.plugins-maven-jar-plugin-3.4.0
Bump org.apache.maven.plugins:maven-jar-plugin from 3.3.0 to 3.4.0
2024-04-14 11:15:33 +02:00
Jérémie Panzer
f3869f92dc Merge pull request #1339 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-config-love-47.0.0
Bump eslint-config-love from 44.0.0 to 47.0.0 in /commafeed-client
2024-04-14 11:15:26 +02:00
dependabot[bot]
b1c1f2adc4 Bump commons-io:commons-io from 2.16.0 to 2.16.1
Bumps commons-io:commons-io from 2.16.0 to 2.16.1.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:12:37 +00:00
Jérémie Panzer
fd8c6c5531 Update dependabot.yml 2024-04-14 11:11:54 +02:00
dependabot[bot]
b37346ad20 Bump @types/react from 18.2.64 to 18.2.78 in /commafeed-client
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.2.64 to 18.2.78.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:09:30 +00:00
dependabot[bot]
1b2e2e6915 Bump com.microsoft.playwright:playwright from 1.41.2 to 1.43.0
Bumps [com.microsoft.playwright:playwright](https://github.com/microsoft/playwright-java) from 1.41.2 to 1.43.0.
- [Release notes](https://github.com/microsoft/playwright-java/releases)
- [Commits](https://github.com/microsoft/playwright-java/compare/v1.41.2...v1.43.0)

---
updated-dependencies:
- dependency-name: com.microsoft.playwright:playwright
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:08:52 +00:00
dependabot[bot]
f483d569f0 Bump org.apache.maven.plugins:maven-jar-plugin from 3.3.0 to 3.4.0
Bumps [org.apache.maven.plugins:maven-jar-plugin](https://github.com/apache/maven-jar-plugin) from 3.3.0 to 3.4.0.
- [Release notes](https://github.com/apache/maven-jar-plugin/releases)
- [Commits](https://github.com/apache/maven-jar-plugin/compare/maven-jar-plugin-3.3.0...maven-jar-plugin-3.4.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-jar-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:08:47 +00:00
dependabot[bot]
b9bbcf1e60 Bump eslint-config-love from 44.0.0 to 47.0.0 in /commafeed-client
Bumps [eslint-config-love](https://github.com/mightyiam/eslint-config-love) from 44.0.0 to 47.0.0.
- [Release notes](https://github.com/mightyiam/eslint-config-love/releases)
- [Changelog](https://github.com/mightyiam/eslint-config-love/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mightyiam/eslint-config-love/compare/v44.0.0...v47.0.0)

---
updated-dependencies:
- dependency-name: eslint-config-love
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-14 09:08:43 +00:00
Athou
3097272179 add button in the header to open entry link (#1333) 2024-04-05 13:38:07 +02:00
Athou
d0b92774bc remove unnecessary curly braces automatically 2024-04-04 08:24:05 +02:00
dependabot[bot]
c82a142c96 Bump vite-tsconfig-paths from 4.3.1 to 4.3.2 in /commafeed-client
Bumps [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) from 4.3.1 to 4.3.2.
- [Release notes](https://github.com/aleclarson/vite-tsconfig-paths/releases)
- [Commits](https://github.com/aleclarson/vite-tsconfig-paths/compare/v4.3.1...v4.3.2)

---
updated-dependencies:
- dependency-name: vite-tsconfig-paths
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 20:27:13 +00:00
Jérémie Panzer
5b43d416fc Merge pull request #1326 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-5.2.7
Bump vite from 5.1.6 to 5.2.7 in /commafeed-client
2024-04-03 22:26:14 +02:00
Athou
40e1c70fca don't try to mark entries that are not markable (#1303) 2024-04-03 15:47:01 +02:00
Athou
d610f980c7 fix typo 2024-04-03 09:11:06 +02:00
dependabot[bot]
68c8ce1ef3 Bump @reduxjs/toolkit from 2.2.1 to 2.2.3 in /commafeed-client
Bumps [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: "@reduxjs/toolkit"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-02 19:54:59 +00:00
dependabot[bot]
218a602c0b Bump vite from 5.1.6 to 5.2.7 in /commafeed-client
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.6 to 5.2.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-02 19:54:07 +00:00
Jérémie Panzer
cad65e953e Merge pull request #1329 from Athou/dependabot/npm_and_yarn/commafeed-client/axios-1.6.8
Bump axios from 1.6.7 to 1.6.8 in /commafeed-client
2024-04-02 21:54:00 +02:00
Athou
39bc9713e4 update eslint-plugin 2024-04-02 21:52:21 +02:00
Jérémie Panzer
812da21b6f Merge pull request #1330 from Athou/dependabot/npm_and_yarn/commafeed-client/eslint-plugin-react-7.34.1
Bump eslint-plugin-react from 7.34.0 to 7.34.1 in /commafeed-client
2024-04-02 21:44:30 +02:00
Athou
a756783604 readd eslint-config-love now that it has been updated 2024-04-02 21:44:06 +02:00
Athou
16199c5b54 add native browser sharing (#1255) 2024-04-01 20:23:44 +02:00
Jérémie Panzer
3964977a0a Merge pull request #1309 from Athou/dependabot/maven/org.apache.maven.plugins-maven-compiler-plugin-3.13.0
Bump org.apache.maven.plugins:maven-compiler-plugin from 3.12.1 to 3.13.0
2024-04-01 20:20:54 +02:00
Jérémie Panzer
f5b4d037ef Merge pull request #1298 from Athou/dependabot/maven/org.postgresql-postgresql-42.7.3
Bump org.postgresql:postgresql from 42.7.2 to 42.7.3
2024-04-01 20:20:46 +02:00
Jérémie Panzer
5929581fee Merge pull request #1316 from Athou/dependabot/maven/commons-io-commons-io-2.16.0
Bump commons-io:commons-io from 2.15.1 to 2.16.0
2024-04-01 20:20:29 +02:00
Jérémie Panzer
92d0d6af47 Merge pull request #1312 from Athou/dependabot/maven/org.projectlombok-lombok-1.18.32
Bump org.projectlombok:lombok from 1.18.30 to 1.18.32
2024-04-01 20:20:22 +02:00
dependabot[bot]
413253e4a9 Bump eslint-plugin-react from 7.34.0 to 7.34.1 in /commafeed-client
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.34.0 to 7.34.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/v7.34.1/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.34.0...v7.34.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:48:49 +00:00
dependabot[bot]
9c98e7eca1 Bump axios from 1.6.7 to 1.6.8 in /commafeed-client
Bumps [axios](https://github.com/axios/axios) from 1.6.7 to 1.6.8.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.7...v1.6.8)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:48:36 +00:00
dependabot[bot]
d3d1aba834 Bump tss-react from 4.9.4 to 4.9.6 in /commafeed-client
Bumps [tss-react](https://github.com/garronej/tss-react) from 4.9.4 to 4.9.6.
- [Release notes](https://github.com/garronej/tss-react/releases)
- [Commits](https://github.com/garronej/tss-react/compare/v4.9.4...v4.9.6)

---
updated-dependencies:
- dependency-name: tss-react
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:48:23 +00:00
dependabot[bot]
6dd3ce2e72 Bump @fontsource/open-sans from 5.0.26 to 5.0.27 in /commafeed-client
Bumps [@fontsource/open-sans](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/open-sans) from 5.0.26 to 5.0.27.
- [Changelog](https://github.com/fontsource/font-files/blob/main/fonts/google/open-sans/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/open-sans)

---
updated-dependencies:
- dependency-name: "@fontsource/open-sans"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:46:51 +00:00
dependabot[bot]
398648ac91 Bump commons-io:commons-io from 2.15.1 to 2.16.0
Bumps commons-io:commons-io from 2.15.1 to 2.16.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 09:18:38 +00:00
Jérémie Panzer
28ef9ccfd2 Merge pull request #1314 from WangLei1993/master
Chinese translations
2024-03-28 23:22:32 +01:00
WangLei1993
acab5295cc change and add some Chinese translation 2024-03-29 05:09:04 +08:00
WangLei1993
3d73435446 change and add some Chinese translation 2024-03-29 05:00:39 +08:00
Jérémie Panzer
eab08d2197 Merge pull request #1313 from maaaathis/patch-1
Update german (DE) locale
2024-03-27 19:15:00 +01:00
mathis
d13b96edd1 fix src/pages/app/AboutPage.tsx message 2024-03-27 18:31:05 +01:00
mathis
624aa9cb23 fix src/pages/app/FeedDetailsPage.tsx message 2024-03-27 18:30:36 +01:00
mathis
e76ee6dc9b update german messages 2024-03-27 18:15:30 +01:00
dependabot[bot]
9bf7dbe893 Bump org.projectlombok:lombok from 1.18.30 to 1.18.32
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.30 to 1.18.32.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.30...v1.18.32)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:31 +00:00
dependabot[bot]
0610080d2a Bump io.github.git-commit-id:git-commit-id-maven-plugin
Bumps [io.github.git-commit-id:git-commit-id-maven-plugin](https://github.com/git-commit-id/git-commit-id-maven-plugin) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/git-commit-id/git-commit-id-maven-plugin/releases)
- [Commits](https://github.com/git-commit-id/git-commit-id-maven-plugin/compare/v8.0.1...v8.0.2)

---
updated-dependencies:
- dependency-name: io.github.git-commit-id:git-commit-id-maven-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:24 +00:00
dependabot[bot]
19d91cf07f Bump io.swagger.core.v3:swagger-maven-plugin-jakarta
Bumps io.swagger.core.v3:swagger-maven-plugin-jakarta from 2.2.20 to 2.2.21.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-maven-plugin-jakarta
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:20 +00:00
dependabot[bot]
d9b9b8c3da Bump org.apache.maven.plugins:maven-compiler-plugin
Bumps [org.apache.maven.plugins:maven-compiler-plugin](https://github.com/apache/maven-compiler-plugin) from 3.12.1 to 3.13.0.
- [Release notes](https://github.com/apache/maven-compiler-plugin/releases)
- [Commits](https://github.com/apache/maven-compiler-plugin/compare/maven-compiler-plugin-3.12.1...maven-compiler-plugin-3.13.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-compiler-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:18 +00:00
dependabot[bot]
0889dc145c Bump io.swagger.core.v3:swagger-annotations from 2.2.20 to 2.2.21
Bumps io.swagger.core.v3:swagger-annotations from 2.2.20 to 2.2.21.

---
updated-dependencies:
- dependency-name: io.swagger.core.v3:swagger-annotations
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:14 +00:00
dependabot[bot]
113a8d49f0 Bump io.github.hakky54:sslcontext-kickstart-for-apache5
Bumps [io.github.hakky54:sslcontext-kickstart-for-apache5](https://github.com/Hakky54/sslcontext-kickstart) from 8.3.2 to 8.3.4.
- [Changelog](https://github.com/Hakky54/sslcontext-kickstart/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Hakky54/sslcontext-kickstart/compare/v8.3.2...v8.3.4)

---
updated-dependencies:
- dependency-name: io.github.hakky54:sslcontext-kickstart-for-apache5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-25 09:29:09 +00:00
Jérémie Panzer
f60a968fb1 Merge pull request #1302 from hywax/master
Update RU locale
2024-03-23 11:28:17 +01:00
hywax
02b060178b Update RU locale №4 2024-03-22 19:20:53 +05:00
hywax
b1f3afd494 Update RU locale №3 2024-03-22 19:17:14 +05:00
hywax
35acac7b93 Update RU locale №2 2024-03-22 19:02:00 +05:00
hywax
9334e7b7a8 Update RU locale 2024-03-22 13:54:48 +05:00
dependabot[bot]
f424314b0d Bump org.postgresql:postgresql from 42.7.2 to 42.7.3
Bumps [org.postgresql:postgresql](https://github.com/pgjdbc/pgjdbc) from 42.7.2 to 42.7.3.
- [Release notes](https://github.com/pgjdbc/pgjdbc/releases)
- [Changelog](https://github.com/pgjdbc/pgjdbc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pgjdbc/pgjdbc/compare/REL42.7.2...REL42.7.3)

---
updated-dependencies:
- dependency-name: org.postgresql:postgresql
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-18 09:30:37 +00:00
dependabot[bot]
c4a9025160 Bump com.google.apis:google-api-services-youtube
Bumps com.google.apis:google-api-services-youtube from v3-rev20240303-2.0.0 to v3-rev20240310-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-youtube
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-18 09:30:33 +00:00
Jérémie Panzer
85a134ef53 Merge pull request #1295 from Athou/dependabot/npm_and_yarn/commafeed-client/follow-redirects-1.15.6
Bump follow-redirects from 1.15.4 to 1.15.6 in /commafeed-client
2024-03-17 10:19:06 +01:00
dependabot[bot]
db1fe0fe91 Bump follow-redirects from 1.15.4 to 1.15.6 in /commafeed-client
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-16 22:39:20 +00:00
Jérémie Panzer
30a45fc329 Merge pull request #1285 from Athou/dependabot/npm_and_yarn/commafeed-client/types/react-dom-18.2.21
Bump @types/react-dom from 18.2.19 to 18.2.21 in /commafeed-client
2024-03-11 17:43:43 +01:00
Jérémie Panzer
ce90fc356c Merge pull request #1294 from Athou/dependabot/npm_and_yarn/commafeed-client/vite-5.1.6
Bump vite from 5.1.4 to 5.1.6 in /commafeed-client
2024-03-11 17:43:18 +01:00
Athou
4f50e34b21 npm packages change too frequently and are only used in the client, reduce interval since it's not critical 2024-03-11 17:42:51 +01:00
Jérémie Panzer
8ccf148eaa Merge pull request #1290 from Athou/dependabot/npm_and_yarn/commafeed-client/monaco-editor-0.47.0
Bump monaco-editor from 0.46.0 to 0.47.0 in /commafeed-client
2024-03-11 17:40:40 +01:00
dependabot[bot]
fa343bda20 Bump @types/react-dom from 18.2.19 to 18.2.21 in /commafeed-client
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 18.2.19 to 18.2.21.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 16:39:57 +00:00
dependabot[bot]
a66f8d7065 Bump vite from 5.1.4 to 5.1.6 in /commafeed-client
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.4 to 5.1.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 16:39:33 +00:00
Jérémie Panzer
819003e43c Merge pull request #1292 from Athou/dependabot/npm_and_yarn/commafeed-client/fontsource/open-sans-5.0.26
Bump @fontsource/open-sans from 5.0.25 to 5.0.26 in /commafeed-client
2024-03-11 17:39:17 +01:00
Jérémie Panzer
27800296fb Merge pull request #1291 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-eslint/eslint-plugin-7.1.1
Bump @typescript-eslint/eslint-plugin from 7.1.0 to 7.1.1 in /commafeed-client
2024-03-11 17:38:54 +01:00
Jérémie Panzer
b869ef072a Merge pull request #1287 from Athou/dependabot/npm_and_yarn/commafeed-client/react-router-dom-6.22.3
Bump react-router-dom from 6.22.2 to 6.22.3 in /commafeed-client
2024-03-11 17:38:46 +01:00
Jérémie Panzer
03dfee468c Merge pull request #1284 from Athou/dependabot/npm_and_yarn/commafeed-client/types/react-18.2.64
Bump @types/react from 18.2.61 to 18.2.64 in /commafeed-client
2024-03-11 17:38:33 +01:00
Jérémie Panzer
d3275074bb Merge pull request #1286 from Athou/dependabot/npm_and_yarn/commafeed-client/typescript-5.4.2
Bump typescript from 5.3.3 to 5.4.2 in /commafeed-client
2024-03-11 11:52:13 +01:00
Jérémie Panzer
cc0965f69c Merge pull request #1293 from Athou/dependabot/github_actions/softprops/action-gh-release-2
Bump softprops/action-gh-release from 1 to 2
2024-03-11 10:49:02 +01:00
Jérémie Panzer
8e2fa3e153 Merge pull request #1289 from Athou/dependabot/maven/io.github.git-commit-id-git-commit-id-maven-plugin-8.0.1
Bump io.github.git-commit-id:git-commit-id-maven-plugin from 8.0.0 to 8.0.1
2024-03-11 10:34:27 +01:00
dependabot[bot]
df10bd7351 Bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:31:04 +00:00
dependabot[bot]
080289ca4e Bump @fontsource/open-sans from 5.0.25 to 5.0.26 in /commafeed-client
Bumps [@fontsource/open-sans](https://github.com/fontsource/font-files/tree/HEAD/fonts/google/open-sans) from 5.0.25 to 5.0.26.
- [Changelog](https://github.com/fontsource/font-files/blob/main/fonts/google/open-sans/CHANGELOG.md)
- [Commits](https://github.com/fontsource/font-files/commits/HEAD/fonts/google/open-sans)

---
updated-dependencies:
- dependency-name: "@fontsource/open-sans"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:26:09 +00:00
dependabot[bot]
28b821f085 Bump @typescript-eslint/eslint-plugin in /commafeed-client
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.1.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:25:55 +00:00
dependabot[bot]
700f3ec029 Bump monaco-editor from 0.46.0 to 0.47.0 in /commafeed-client
Bumps [monaco-editor](https://github.com/microsoft/monaco-editor) from 0.46.0 to 0.47.0.
- [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/monaco-editor/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: monaco-editor
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:25:15 +00:00
dependabot[bot]
d7b3ed0baa Bump io.github.git-commit-id:git-commit-id-maven-plugin
Bumps [io.github.git-commit-id:git-commit-id-maven-plugin](https://github.com/git-commit-id/git-commit-id-maven-plugin) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/git-commit-id/git-commit-id-maven-plugin/releases)
- [Commits](https://github.com/git-commit-id/git-commit-id-maven-plugin/compare/v8.0.0...v8.0.1)

---
updated-dependencies:
- dependency-name: io.github.git-commit-id:git-commit-id-maven-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:25:07 +00:00
dependabot[bot]
f1711014e5 Bump react-router-dom from 6.22.2 to 6.22.3 in /commafeed-client
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.22.2 to 6.22.3.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.22.3/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:24:27 +00:00
dependabot[bot]
29f2270443 Bump typescript from 5.3.3 to 5.4.2 in /commafeed-client
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.3 to 5.4.2.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.3...v5.4.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:24:02 +00:00
dependabot[bot]
39ee4e771c Bump @types/react from 18.2.61 to 18.2.64 in /commafeed-client
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.2.61 to 18.2.64.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 09:23:25 +00:00
Jérémie Panzer
6e4d2d57fa Merge pull request #1283 from Athou/dependabot/maven/io.github.git-commit-id-git-commit-id-maven-plugin-8.0.0
Bump io.github.git-commit-id:git-commit-id-maven-plugin from 7.0.0 to 8.0.0
2024-03-07 20:02:15 +01:00
Jérémie Panzer
7cd850a2e8 Merge pull request #1282 from Athou/dependabot/maven/io.dropwizard-dropwizard-dependencies-4.0.7
Bump io.dropwizard:dropwizard-dependencies from 4.0.6 to 4.0.7
2024-03-07 20:01:32 +01:00
Jérémie Panzer
3280023823 Merge pull request #1281 from Athou/dependabot/maven/com.google.apis-google-api-services-youtube-v3-rev20240303-2.0.0
Bump com.google.apis:google-api-services-youtube from v3-rev20240225-2.0.0 to v3-rev20240303-2.0.0
2024-03-07 20:01:19 +01:00
Jérémie Panzer
3646a9610e Merge pull request #1280 from Athou/dependabot/maven/redis.clients-jedis-5.1.2
Bump redis.clients:jedis from 5.1.1 to 5.1.2
2024-03-07 20:00:51 +01:00
dependabot[bot]
ab639b3ee6 Bump io.github.git-commit-id:git-commit-id-maven-plugin
Bumps [io.github.git-commit-id:git-commit-id-maven-plugin](https://github.com/git-commit-id/git-commit-id-maven-plugin) from 7.0.0 to 8.0.0.
- [Release notes](https://github.com/git-commit-id/git-commit-id-maven-plugin/releases)
- [Commits](https://github.com/git-commit-id/git-commit-id-maven-plugin/compare/v7.0.0...v8.0.0)

---
updated-dependencies:
- dependency-name: io.github.git-commit-id:git-commit-id-maven-plugin
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-07 18:55:21 +00:00
dependabot[bot]
c85ba3fa75 Bump io.dropwizard:dropwizard-dependencies from 4.0.6 to 4.0.7
Bumps io.dropwizard:dropwizard-dependencies from 4.0.6 to 4.0.7.

---
updated-dependencies:
- dependency-name: io.dropwizard:dropwizard-dependencies
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-07 18:55:15 +00:00
dependabot[bot]
193c2aecfb Bump com.google.apis:google-api-services-youtube
Bumps com.google.apis:google-api-services-youtube from v3-rev20240225-2.0.0 to v3-rev20240303-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-youtube
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-07 18:55:10 +00:00
dependabot[bot]
336de875ca Bump redis.clients:jedis from 5.1.1 to 5.1.2
Bumps [redis.clients:jedis](https://github.com/redis/jedis) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/redis/jedis/releases)
- [Commits](https://github.com/redis/jedis/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: redis.clients:jedis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-07 18:54:08 +00:00
Athou
eb5012f67e accept .opml extension for opml import 2024-03-05 20:57:08 +01:00
Athou
5c764b9b25 use .opml extension for opml file export 2024-03-05 20:56:51 +01:00
Athou
5da94a7ed0 release 4.3.3 2024-03-05 18:33:44 +01:00
Athou
dfb3006c47 fix OMPL import (#1279) 2024-03-05 18:32:52 +01:00
Athou
e626f36c0a workaround no longer needed 2024-03-05 17:56:52 +01:00
345 changed files with 24476 additions and 22245 deletions

View File

@@ -1,6 +1 @@
# ignore everything commafeed-client
*
# allow only what we need
!commafeed-server/target/commafeed.jar
!commafeed-server/config.yml.example

7
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,5 @@
github: [athou] github: [ athou ]
custom: ['https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed&currency_code=EUR'] custom: [
'https://github.com/sponsors/Athou',
'https://www.paypal.com/donate/?business=9CNQHMJG2ZJVY&no_recurring=0&item_name=CommaFeed&currency_code=EUR'
]

View File

@@ -25,7 +25,8 @@ If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- CommaFeed version (or "commafeed.com"): 3.2.1 - commafeed.com or self-hosted:
- If self-hosted, CommaFeed version [e.g. 5.1.0 (a3dcb2c)]:
- Browser [e.g. chrome, firefox]: - Browser [e.g. chrome, firefox]:
- Device [e.g. desktop, mobile]: - Device [e.g. desktop, mobile]:

View File

@@ -1,24 +0,0 @@
version: 2
updates:
- package-ecosystem: "maven"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
- package-ecosystem: "npm"
directory: "/commafeed-client"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
groups:
mantine:
patterns:
- "@mantine/*"
lingui:
patterns:
- "@lingui/*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10

View File

@@ -1,89 +0,0 @@
name: Java CI
on: [ push ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ "17", "21" ]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Setup
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Java
uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: "temurin"
cache: "maven"
# Build
- name: Build with Maven
run: mvn --batch-mode --update-snapshots verify
- name: Upload JAR
uses: actions/upload-artifact@v4
if: ${{ matrix.java == '17' }}
with:
name: commafeed.jar
path: commafeed-server/target/commafeed.jar
# Docker
- name: Login to Container Registry
uses: docker/login-action@v3
if: ${{ matrix.java == '17' && (github.ref_type == 'tag' || github.ref_name == 'master') }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker build and push tag
uses: docker/build-push-action@v5
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
tags: |
athou/commafeed:latest
athou/commafeed:${{ github.ref_name }}
- name: Docker build and push master
uses: docker/build-push-action@v5
if: ${{ matrix.java == '17' && github.ref_name == 'master' }}
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
tags: athou/commafeed:master
# Create GitHub release after Docker image has been published
- name: Extract Changelog Entry
uses: mindsers/changelog-reader-action@v2
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
id: changelog_reader
with:
version: ${{ github.ref_name }}
- name: Create GitHub release
uses: softprops/action-gh-release@v1
if: ${{ matrix.java == '17' && github.ref_type == 'tag' }}
with:
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}
draft: false
prerelease: false
files: |
commafeed-server/target/commafeed.jar
commafeed-server/config.yml.example

183
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,183 @@
name: ci
on: [ push ]
env:
JAVA_VERSION: 21
DOCKER_BUILD_SUMMARY: false
jobs:
build-linux:
runs-on: ubuntu-latest
strategy:
matrix:
database: [ "h2", "postgresql", "mysql", "mariadb" ]
steps:
# Checkout
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Setup
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
cache: "maven"
# Build & Test
- name: Build with Maven
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }}
# Upload artifacts
- name: Upload cross-platform app
uses: actions/upload-artifact@v4
with:
name: commafeed-${{ matrix.database }}-jvm
path: commafeed-server/target/commafeed-*.zip
- name: Upload native executable
uses: actions/upload-artifact@v4
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner
# Docker
- name: Login to Container Registry
uses: docker/login-action@v3
if: ${{ github.ref_type == 'tag' || github.ref_name == 'master' }}
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
## tags
- name: Docker build and push tag - native
uses: docker/build-push-action@v6
if: ${{ github.ref_type == 'tag' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
push: true
platforms: linux/amd64
tags: |
athou/commafeed:latest-${{ matrix.database }}
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}
- name: Docker build and push tag - jvm
uses: docker/build-push-action@v6
if: ${{ github.ref_type == 'tag' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
push: true
platforms: linux/amd64,linux/arm64/v8
tags: |
athou/commafeed:latest-${{ matrix.database }}-jvm
athou/commafeed:${{ github.ref_name }}-${{ matrix.database }}-jvm
## master
- name: Docker build and push master - native
uses: docker/build-push-action@v6
if: ${{ github.ref_name == 'master' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.native
push: true
platforms: linux/amd64
tags: athou/commafeed:master-${{ matrix.database }}
- name: Docker build and push master - jvm
uses: docker/build-push-action@v6
if: ${{ github.ref_name == 'master' }}
with:
context: .
file: commafeed-server/src/main/docker/Dockerfile.jvm
push: true
platforms: linux/amd64,linux/arm64/v8
tags: athou/commafeed:master-${{ matrix.database }}-jvm
build-windows:
runs-on: windows-latest
strategy:
matrix:
database: [ "h2", "postgresql", "mysql", "mariadb" ]
steps:
# Checkout
- name: Configure git to checkout as-is
run: git config --global core.autocrlf false
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Setup
- name: Set up GraalVM
uses: graalvm/setup-graalvm@v1
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: "graalvm"
cache: "maven"
# Build & Test
- name: Build with Maven
run: mvn --batch-mode --no-transfer-progress install -Pnative -P${{ matrix.database }} -DskipTests=${{ matrix.database != 'h2' }}
# Upload artifacts
- name: Upload native executable
uses: actions/upload-artifact@v4
with:
name: commafeed-${{ matrix.database }}-${{ runner.os }}-${{ runner.arch }}
path: commafeed-server/target/commafeed-*-runner.exe
release:
runs-on: ubuntu-latest
needs:
- build-linux
- build-windows
if: github.ref_type == 'tag'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: commafeed-*
path: ./artifacts
merge-multiple: true
- name: Extract Changelog Entry
uses: mindsers/changelog-reader-action@v2
id: changelog_reader
with:
version: ${{ github.ref_name }}
- name: Create GitHub release
uses: ncipollo/release-action@v1
with:
name: CommaFeed ${{ github.ref_name }}
body: ${{ steps.changelog_reader.outputs.changes }}
artifacts: ./artifacts/*
- name: Update Docker Hub Description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: athou/commafeed
short-description: ${{ github.event.repository.description }}
readme-filepath: commafeed-server/src/main/docker/README.md

Binary file not shown.

View File

@@ -6,7 +6,7 @@
# "License"); you may not use this file except in compliance # "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at # with the License. You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0 # http://www.apache.org/licenses/LICENSE-2.0
# #
# Unless required by applicable law or agreed to in writing, # Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an # software distributed under the License is distributed on an
@@ -14,5 +14,5 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip distributionType=only-script
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

View File

@@ -1,5 +1,110 @@
# Changelog # Changelog
## [5.3.1]
- Fixed an issue that could cause some HTTP feeds to return a 400 error (#1572)
## [5.3.0]
- Added a setting to set a cooldown on the "fetch all my feeds" action, disabled by default (#1556)
- Fixed an issue that could cause entries to not correctly load when using the "next" header button (#1557)
## [5.2.0]
- 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)
- 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)
## [5.1.1]
- 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)
## [5.1.0]
- 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)
- 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
## [5.0.2]
- Fix favicon fetching for Youtube channels in native mode when Google auth key is set
- Fix an error that appears in the logs when fetching some favicons
## [5.0.1]
- Configure native compilation to support older CPU architectures (#1524)
## [5.0.0]
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 gist of it is that CommaFeed can now be compiled to a native binary, resulting in blazing fast startup times (around
0.3s) and very low memory footprint (< 50M).
- CommaFeed now has a different package for each supported database.
- If you are deploying CommaFeed with a precompiled package, please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#download-a-precompiled-package).
- If you are building CommaFeed from sources, please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#build-from-sources).
- If you are using the Docker image, please read the instructions on
the [Docker Hub page](https://hub.docker.com/r/athou/commafeed).
- Due to the switch to Quarkus, the way CommaFeed is configured is very different (the `config.yml` file is gone).
Please
read [this section of the README](https://github.com/Athou/commafeed/tree/master?tab=readme-ov-file#configuration).
Note that a lot of configuration elements have been removed or renamed and are now nested/grouped by feature.
- Added a setting to prevent parsing large feeds to avoid out of memory errors. The default is 5MB.
- Use a different icon for filtering unread entries and marking an entry as read (#1506)
- Added various HTML attributes to ease custom JS/CSS customization (#1507)
- The Redis cache has been removed. There have been multiple enhancements to the feed refresh engine and it is no longer
needed, even for instances with a large number of feeds.
- The H2 migration tool that automatically upgrades H2 databases from format 2 to 3 has been removed. If you're using
the H2 embedded database, please upgrade to at least version 4.3.0 before upgrading to CommaFeed 5.0.0.
## [4.6.0]
- switched from Temurin to OpenJ9 as the JVM used in the Docker image, resulting in memory usage reduction by up to 50%
- fix an issue that could cause old entries to reappear if they were updated by their author (#1486)
- show all entries regardless of their read status when searching with keywords, even if the ui is configured to show
unread entries only
## [4.5.0]
- significantly reduce the time needed to retrieve entries or mark them as read, especially when there are a lot of
entries (#1452)
- fix a race condition where a feed could be refreshed before it was created in the database
- fix an issue that could cause the websocket notification to contain the wrong number of unread entries when using
mysql/mariadb
- fix an error when trying to mark all starred entries as read
- remove the `onlyIds` parameter from REST endpoints since retrieving all the entries is now just as fast
- remove support for microsoft sqlserver because it's not covered with integration tests (please open an issue if you'd
like it back)
## [4.4.1]
- fix vertical scrolling issues with Safari (#1168)
- the default value for new users for the "star entry" button and the "open in new tab" button in the entry headers is
now "on desktop" instead of "always"
- the "keyboard shortcuts" help page now shows "Cmd" instead of "Ctrl" on macOS (#1389)
- remove a superfluous feed fetch when subscribing to a feed (#1431)
- the Docker image now uses Java 21
## [4.4.0]
- add support for sharing using the browser native capabilities if available (#1255)
- add a button in the entry headers to star an entry (#1025)
- 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
- 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 (
older than `keepStatusDays`) (#1303)
## [4.3.3]
- fix OPML import (#1279)
## [4.3.2] ## [4.3.2]
- added support for unix sockets (#1278) - added support for unix sockets (#1278)

View File

@@ -1,12 +0,0 @@
FROM eclipse-temurin:17-jre
EXPOSE 8082
RUN mkdir -p /commafeed/data
VOLUME /commafeed/data
COPY commafeed-server/config.yml.example config.yml
COPY commafeed-server/target/commafeed.jar .
ENV JAVA_TOOL_OPTIONS -Djava.net.preferIPv4Stack=true -Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
CMD ["java", "-jar", "commafeed.jar", "server", "config.yml"]

116
README.md
View File

@@ -1,6 +1,6 @@
# CommaFeed # CommaFeed
Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/TypeScript. Google Reader inspired self-hosted RSS reader, based on Quarkus and React/TypeScript.
![preview](https://user-images.githubusercontent.com/1256795/184886828-1973f148-58a9-4c6d-9587-ee5e5d3cc2cb.png) ![preview](https://user-images.githubusercontent.com/1256795/184886828-1973f148-58a9-4c6d-9587-ee5e5d3cc2cb.png)
@@ -8,14 +8,22 @@ Google Reader inspired self-hosted RSS reader, based on Dropwizard and React/Typ
- 4 different layouts - 4 different layouts
- Light/Dark theme - Light/Dark theme
- Fully responsive - Fully responsive, works great on both mobile and desktop
- Keyboard shortcuts for almost everything - Keyboard shortcuts for almost everything
- Support for right-to-left feeds - Support for right-to-left feeds
- Translated in 25+ languages - Translated in 25+ languages
- Supports thousands of users and millions of feeds - Supports thousands of users and millions of feeds
- OPML import/export - OPML import/export
- REST API and a Fever-compatible API for native mobile apps - REST API
- Fever-compatible API for native mobile apps
- Can automatically mark articles as read based on user-defined rules
- [Browser extension](https://github.com/Athou/commafeed-browser-extension) - [Browser extension](https://github.com/Athou/commafeed-browser-extension)
- Compiles to native code for blazing fast startup and low memory usage
- Supports 4 databases
- H2 (embedded database)
- PostgreSQL
- MySQL
- MariaDB
## Deployment ## Deployment
@@ -33,32 +41,84 @@ PikaPods shares 20% of the revenue back to CommaFeed.
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=commafeed) [![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=commafeed)
### Download precompiled package ### Download a precompiled package
mkdir commafeed && cd commafeed Go to the [release page](https://github.com/Athou/commafeed/releases) and download the latest version for your operating
wget https://github.com/Athou/commafeed/releases/latest/download/commafeed.jar system and database of choice.
wget https://github.com/Athou/commafeed/releases/latest/download/config.yml.example -O config.yml
java -Djava.net.preferIPv4Stack=true -jar commafeed.jar server config.yml
The server will listen on http://localhost:8082. The default There are two types of packages:
user is `admin` and the default password is `admin`.
- The `linux-x86_64` and `windows-x86_64` packages are compiled natively and contain an executable that can be run
directly.
- The `jvm` package is a zip file containing all `.jar` files required to run the application. This package works on all
platforms and is started with `java -jar quarkus-run.jar`.
If available for your operating system, the native package is recommended because it has a faster startup time and lower
memory usage.
### Build from sources ### Build from sources
git clone https://github.com/Athou/commafeed.git ./mvnw clean package [-P<database>] [-Pnative] [-DskipTests]
cd commafeed
./mvnw clean package
cp commafeed-server/config.yml.example config.yml
java -Djava.net.preferIPv4Stack=true -jar commafeed-server/target/commafeed.jar server config.yml
The server will listen on http://localhost:8082. The default - `<database>` can be one of `h2`, `postgresql`, `mysql` or `mariadb`. The default is `h2`.
user is `admin` and the default password is `admin`. - `-Pnative` compiles the application to native code. This requires GraalVM to be installed (`GRAALVM_HOME` environment
variable pointing to a GraalVM installation).
- `-DskipTests` to speed up the build process by skipping tests.
### Memory management When the build is complete:
- a zip containing all jars required to run the application is located at
`commafeed-server/target/commafeed-<version>-<database>-jvm.zip`. Extract it and run the application with
`java -jar quarkus-run.jar`
- if you used the native profile, the executable is located at
`commafeed-server/target/commafeed-<version>-<database>-<platform>-<arch>-runner[.exe]`
## Configuration
CommaFeed doesn't require any configuration to run with its embedded database (H2). The database file will be stored in
the `data` directory of the current directory.
To use a different database, you will need to configure the following properties:
- `quarkus.datasource.jdbc.url`
- e.g. for H2: `jdbc:h2:./data/db;DEFRAG_ALWAYS=TRUE`
- e.g. for PostgreSQL: `jdbc:postgresql://localhost:5432/commafeed`
- e.g. for MySQL:
`jdbc:mysql://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
- e.g. for MariaDB:
`jdbc:mariadb://localhost/commafeed?autoReconnect=true&failOverReadOnly=false&maxReconnects=20&rewriteBatchedStatements=true&timezone=UTC`
- `quarkus.datasource.username`
- `quarkus.datasource.password`
There are multiple ways to configure CommaFeed:
- a `config/application.properties` [properties](https://en.wikipedia.org/wiki/.properties) file relative to the working
directory (keys in kebab-case)
- Command line arguments prefixed with `-D` (keys in kebab-case)
- Environment variables (keys in UPPER_CASE)
- a `.env` file in the working directory (keys in UPPER_CASE)
The properties file is recommended because CommaFeed will be able to warn about invalid properties and typos.
All [CommaFeed settings](commafeed-server/doc/commafeed.adoc) are optional and have sensible default values.
When logging in, credentials are stored in an encrypted cookie. The encryption key is randomly generated at startup,
meaning that you will have to log back in after each restart of the application. To prevent this, you can set the
`quarkus.http.auth.session.encryption-key` property to a fixed value (min. 16 characters).
All other Quarkus settings can be found [here](https://quarkus.io/guides/all-config).
When started, the server will listen on http://localhost:8082.
The default user is `admin` and the default password is `admin`.
### Updates
When CommaFeed is up and running, you can subscribe to [this feed](https://github.com/Athou/commafeed/releases.atom) to be notified of new releases.
### Memory management (`jvm` package only)
The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the The Java Virtual Machine (JVM) is rather greedy by default and will not release unused memory to the
operating system. This is because acquiring memory from the operating system is a relatively expensive operation. operating system. This is because acquiring memory from the operating system is a relatively expensive operation.
However, this can be problematic on systems with limited memory. This can be problematic on systems with limited memory.
#### Hard limit #### Hard limit
@@ -67,16 +127,26 @@ For example, to limit the JVM to 256MB of memory, use `-Xmx256m`.
#### Dynamic sizing #### Dynamic sizing
The JVM can be configured to release unused memory to the operating system with the following parameters: In addition to the previous setting, the JVM can be configured to release unused memory to the operating system with the
following parameters:
-Xms20m -XX:+UseG1GC -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10 -Xms20m -XX:+UseG1GC -XX:+UseStringDeduplication -XX:-ShrinkHeapInSteps -XX:G1PeriodicGCInterval=10000 -XX:-G1PeriodicGCInvokesConcurrent -XX:MinHeapFreeRatio=5 -XX:MaxHeapFreeRatio=10
This is how the Docker image is configured.
See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html) See [here](https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html)
and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for and [here](https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html) for
more more
information. information.
#### OpenJ9
The [OpenJ9](https://eclipse.dev/openj9/) JVM is a more memory-efficient alternative to the HotSpot JVM, at the cost of
slightly slower throughput.
IBM provides precompiled binaries for OpenJ9
named [Semeru](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/).
This is the JVM used in
the [Docker image](https://github.com/Athou/commafeed/blob/master/commafeed-server/src/main/docker/Dockerfile.jvm).
## Translation ## Translation
Files for internationalization are Files for internationalization are
@@ -99,7 +169,7 @@ two-letters [ISO-639-1 language code](http://en.wikipedia.org/wiki/List_of_ISO_6
- Open `commafeed-server` in your preferred Java IDE. - Open `commafeed-server` in your preferred Java IDE.
- CommaFeed uses Lombok, you need the Lombok plugin for your IDE. - CommaFeed uses Lombok, you need the Lombok plugin for your IDE.
- Start `CommaFeedApplication.java` in debug mode with `server config.dev.yml` as arguments - run `./mvnw quarkus:dev`
### Frontend ### Frontend

View File

@@ -1,8 +0,0 @@
dist
node_modules
vite.config.ts
# compiled linguijs locales
# they no longer exist but we keep this to avoid issues with people still having those files on disk
src/locales/**/*.ts

View File

@@ -1,47 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
"eslint:recommended",
"standard",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended",
],
settings: {
react: {
version: "detect",
},
},
overrides: [
{
env: {
node: true,
},
files: [".eslintrc.{js,cjs}"],
parserOptions: {
sourceType: "script",
},
},
],
parserOptions: {
project: true,
ecmaVersion: "latest",
sourceType: "module",
},
plugins: ["react"],
rules: {
"@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }],
"@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }],
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/prefer-nullish-coalescing": ["error", { ignoreConditionalTests: true }],
"react/no-unescaped-entities": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "error",
},
}

View File

@@ -1,52 +0,0 @@
{
"locales": [
"ar",
"ca",
"cs",
"cy",
"da",
"de",
"en",
"es",
"fa",
"fi",
"fr",
"gl",
"hu",
"id",
"it",
"ja",
"ko",
"ms",
"nb",
"nl",
"nn",
"pl",
"pt",
"ru",
"sk",
"sv",
"tr",
"zh"
],
"catalogs": [
{
"path": "src/locales/{locale}/messages",
"include": [
"src"
],
"exclude": [
"src/locales/**"
]
}
],
"format": "po",
"formatOptions": {
"origins": true,
"lineNumbers": false
},
"sourceLocale": "en",
"fallbackLocales": {
"default": "en"
}
}

View File

@@ -1,8 +0,0 @@
{
"printWidth": 140,
"semi": false,
"tabWidth": 4,
"arrowParens": "avoid",
"endOfLine": "auto",
"trailingComma": "es5"
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"formatter": {
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 140
},
"javascript": {
"formatter": {
"trailingCommas": "es5",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded"
}
},
"files": {
"ignore": ["dist", "node_modules", "target", "target-ide"]
}
}

View File

@@ -0,0 +1,52 @@
import type { LinguiConfig } from "@lingui/conf"
const config: LinguiConfig = {
locales: [
"ar",
"ca",
"cs",
"cy",
"da",
"de",
"en",
"es",
"fa",
"fi",
"fr",
"gl",
"hu",
"id",
"it",
"ja",
"ko",
"ms",
"nb",
"nl",
"nn",
"pl",
"pt",
"ru",
"sk",
"sv",
"tr",
"zh",
],
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"],
exclude: ["src/locales/**"],
},
],
format: "po",
formatOptions: {
origins: true,
lineNumbers: false,
},
sourceLocale: "en",
fallbackLocales: {
default: "en",
},
}
export default config

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1,79 @@
{ {
"name": "commafeed-client", "name": "commafeed-client",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"dev:typescript": "tsc --watch", "dev:typescript": "tsc --watch",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:ci": "vitest run", "test:ci": "vitest run",
"eslint": "eslint --ext=.js,.jsx,.ts,.tsx src", "lint": "biome check ./src",
"i18n:extract": "lingui extract --clean" "lint:fix": "biome check --write ./src",
}, "i18n:extract": "lingui extract --clean"
"dependencies": { },
"@emotion/react": "^11.11.4", "dependencies": {
"@fontsource/open-sans": "^5.0.25", "@emotion/react": "^11.13.3",
"@lingui/core": "^4.7.1", "@fontsource/open-sans": "^5.1.0",
"@lingui/macro": "^4.7.1", "@lingui/core": "^4.11.4",
"@lingui/react": "^4.7.1", "@lingui/macro": "^4.11.4",
"@mantine/core": "^7.6.1", "@lingui/react": "^4.11.4",
"@mantine/form": "^7.6.1", "@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.6.1", "@mantine/form": "^7.13.2",
"@mantine/modals": "^7.6.1", "@mantine/hooks": "^7.13.2",
"@mantine/notifications": "^7.6.1", "@mantine/modals": "^7.13.2",
"@mantine/spotlight": "^7.6.1", "@mantine/notifications": "^7.13.2",
"@monaco-editor/react": "^4.6.0", "@mantine/spotlight": "^7.13.2",
"@reduxjs/toolkit": "^2.2.1", "@monaco-editor/react": "^4.6.0",
"axios": "^1.6.7", "@reduxjs/toolkit": "^2.2.7",
"dayjs": "^1.11.10", "axios": "^1.7.7",
"escape-string-regexp": "^5.0.0", "dayjs": "^1.11.13",
"interweave": "^13.1.0", "escape-string-regexp": "^5.0.0",
"monaco-editor": "^0.46.0", "interweave": "^13.1.0",
"mousetrap": "^1.6.5", "monaco-editor": "^0.52.0",
"react": "^18.2.0", "mousetrap": "^1.6.5",
"react-async-hook": "^4.0.0", "react": "^18.3.1",
"react-contexify": "^6.0.0", "react-async-hook": "^4.0.0",
"react-dom": "^18.2.0", "react-contexify": "^6.0.0",
"react-draggable": "^4.4.6", "react-device-detect": "^2.2.3",
"react-ga4": "^2.1.0", "react-dom": "^18.3.1",
"react-icons": "^5.0.1", "react-draggable": "^4.4.6",
"react-infinite-scroller": "^1.2.6", "react-ga4": "^2.1.0",
"react-redux": "^9.1.0", "react-helmet": "^6.1.0",
"react-router-dom": "^6.22.2", "react-icons": "^5.3.0",
"react-swipeable": "^7.0.1", "react-infinite-scroller": "^1.2.6",
"redoc": "^2.1.3", "react-redux": "^9.1.2",
"throttle-debounce": "^5.0.0", "react-router-dom": "^6.26.2",
"tinycon": "^0.6.8", "react-swipeable": "^7.0.1",
"tss-react": "^4.9.4", "redoc": "^2.1.5",
"use-local-storage": "^3.0.0", "throttle-debounce": "^5.0.2",
"websocket-heartbeat-js": "^1.1.3" "tinycon": "^0.6.8",
}, "tss-react": "^4.9.13",
"devDependencies": { "websocket-heartbeat-js": "^1.1.3"
"@lingui/cli": "^4.7.1", },
"@lingui/vite-plugin": "^4.7.1", "devDependencies": {
"@types/mousetrap": "^1.6.15", "@biomejs/biome": "^1.9.3",
"@types/react": "^18.2.61", "@lingui/cli": "^4.11.4",
"@types/react-dom": "^18.2.19", "@lingui/vite-plugin": "^4.11.4",
"@types/react-infinite-scroller": "^1.2.5", "@types/mousetrap": "^1.6.15",
"@types/swagger-ui-react": "^4.18.3", "@types/react": "^18.3.11",
"@types/throttle-debounce": "^5.0.2", "@types/react-dom": "^18.3.0",
"@types/tinycon": "^0.6.5", "@types/react-helmet": "^6.1.11",
"@typescript-eslint/eslint-plugin": "^7.1.0", "@types/react-infinite-scroller": "^1.2.5",
"@vitejs/plugin-react": "^4.2.1", "@types/swagger-ui-react": "^4.18.3",
"babel-plugin-macros": "^3.1.0", "@types/throttle-debounce": "^5.0.2",
"eslint": "^8.57.0", "@types/tinycon": "^0.6.5",
"eslint-config-prettier": "^9.1.0", "@vitejs/plugin-react": "^4.3.2",
"eslint-config-standard": "^17.1.0", "babel-plugin-macros": "^3.1.0",
"eslint-plugin-prettier": "^5.1.3", "jsdom": "^25.0.1",
"eslint-plugin-react": "^7.34.0", "rollup-plugin-visualizer": "^5.12.0",
"eslint-plugin-react-hooks": "^4.6.0", "typescript": "^5.6.2",
"prettier": "^3.2.5", "vite": "^5.4.8",
"rollup-plugin-visualizer": "^5.12.0", "vite-plugin-checker": "^0.8.0",
"typescript": "^5.3.3", "vite-tsconfig-paths": "^5.0.1",
"vite": "^5.1.4", "vitest": "^2.1.2",
"vite-plugin-eslint": "^1.8.1", "vitest-mock-extended": "^2.0.2"
"vite-tsconfig-paths": "^4.3.1", }
"vitest": "^1.3.1",
"vitest-mock-extended": "^1.3.1"
}
} }

View File

@@ -1,21 +1,29 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>com.commafeed</groupId> <groupId>com.commafeed</groupId>
<artifactId>commafeed</artifactId> <artifactId>commafeed</artifactId>
<version>4.3.2</version> <version>5.3.1</version>
</parent> </parent>
<artifactId>commafeed-client</artifactId> <artifactId>commafeed-client</artifactId>
<name>CommaFeed Client</name> <name>CommaFeed Client</name>
<properties>
<!-- renovate: datasource=node-version depName=node -->
<node.version>v20.18.0</node.version>
<!-- renovate: datasource=npm depName=npm -->
<npm.version>10.9.0</npm.version>
</properties>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
<groupId>com.github.eirslett</groupId> <groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId> <artifactId>frontend-maven-plugin</artifactId>
<version>1.15.0</version> <version>1.15.1</version>
<?m2e ignore?> <?m2e ignore?>
<executions> <executions>
<execution> <execution>
@@ -25,8 +33,8 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<nodeVersion>v20.10.0</nodeVersion> <nodeVersion>${node.version}</nodeVersion>
<npmVersion>10.2.5</npmVersion> <npmVersion>${npm.version}</npmVersion>
</configuration> </configuration>
</execution> </execution>
<execution> <execution>
@@ -72,7 +80,7 @@
<goal>copy-resources</goal> <goal>copy-resources</goal>
</goals> </goals>
<configuration> <configuration>
<outputDirectory>${project.build.directory}/classes/assets</outputDirectory> <outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
<resources> <resources>
<resource> <resource>
<directory>dist</directory> <directory>dist</directory>

View File

@@ -1,7 +1,6 @@
import { i18n } from "@lingui/core" import { i18n } from "@lingui/core"
import { I18nProvider } from "@lingui/react" import { I18nProvider } from "@lingui/react"
import { MantineProvider } from "@mantine/core" import { MantineProvider } from "@mantine/core"
import { useDidUpdate } from "@mantine/hooks"
import { ModalsProvider } from "@mantine/modals" import { ModalsProvider } from "@mantine/modals"
import { Notifications } from "@mantine/notifications" import { Notifications } from "@mantine/notifications"
import { Constants } from "app/constants" import { Constants } from "app/constants"
@@ -9,11 +8,13 @@ import { redirectTo } from "app/redirect/slice"
import { reloadServerInfos } from "app/server/thunks" import { reloadServerInfos } from "app/server/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { categoryUnreadCount } from "app/utils" import { categoryUnreadCount } from "app/utils"
import { DisablePullToRefresh } from "components/DisablePullToRefresh"
import { ErrorBoundary } from "components/ErrorBoundary" import { ErrorBoundary } from "components/ErrorBoundary"
import { Header } from "components/header/Header" import { Header } from "components/header/Header"
import { Tree } from "components/sidebar/Tree" import { Tree } from "components/sidebar/Tree"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useI18n } from "i18n" import { useI18n } from "i18n"
import { WelcomePage } from "pages/WelcomePage"
import { AdminUsersPage } from "pages/admin/AdminUsersPage" import { AdminUsersPage } from "pages/admin/AdminUsersPage"
import { MetricsPage } from "pages/admin/MetricsPage" import { MetricsPage } from "pages/admin/MetricsPage"
import { AboutPage } from "pages/app/AboutPage" import { AboutPage } from "pages/app/AboutPage"
@@ -28,9 +29,10 @@ import { TagDetailsPage } from "pages/app/TagDetailsPage"
import { LoginPage } from "pages/auth/LoginPage" import { LoginPage } from "pages/auth/LoginPage"
import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage" import { PasswordRecoveryPage } from "pages/auth/PasswordRecoveryPage"
import { RegistrationPage } from "pages/auth/RegistrationPage" import { RegistrationPage } from "pages/auth/RegistrationPage"
import { WelcomePage } from "pages/WelcomePage" import React, { useEffect } from "react"
import React, { useEffect, useRef } from "react" import { isSafari } from "react-device-detect"
import ReactGA from "react-ga4" import ReactGA from "react-ga4"
import { Helmet } from "react-helmet"
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom" import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"
import Tinycon from "tinycon" import Tinycon from "tinycon"
@@ -69,7 +71,7 @@ function Providers(props: { children: React.ReactNode }) {
) )
} }
// swagger-ui is very large, load only on-demand // api documentation page is very large, load only on-demand
const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage")) const ApiDocumentationPage = React.lazy(async () => await import("pages/app/ApiDocumentationPage"))
function AppRoutes() { function AppRoutes() {
@@ -140,16 +142,18 @@ function GoogleAnalyticsHandler() {
return null return null
} }
function FaviconHandler() { function UnreadCountTitleHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
const root = useAppSelector(state => state.tree.rootCategory) return <Helmet title={enabled && unreadCount > 0 ? `(${unreadCount}) CommaFeed` : "CommaFeed"} />
}
function UnreadCountFaviconHandler({ unreadCount, enabled }: { unreadCount: number; enabled?: boolean }) {
useEffect(() => { useEffect(() => {
const unreadCount = categoryUnreadCount(root) if (enabled && unreadCount > 0) {
if (unreadCount === 0) {
Tinycon.reset()
} else {
Tinycon.setBubble(unreadCount) Tinycon.setBubble(unreadCount)
} else {
Tinycon.reset()
} }
}, [root]) }, [unreadCount, enabled])
return null return null
} }
@@ -166,44 +170,24 @@ function BrowserExtensionBadgeUnreadCountHandler() {
return null return null
} }
function CustomJs() { function CustomCode() {
const scriptLoaded = useRef(false) return (
<Helmet>
// useDidUpdate is used instead of useEffect because we want to skip the first render <link rel="stylesheet" type="text/css" href="custom_css.css" />
// the first render is the render of react-router, the routes are actually loaded in a second render <script type="text/javascript" src="custom_js.js" />
// we want the script to be executed when the first route is done loading </Helmet>
useDidUpdate(() => { )
if (scriptLoaded.current) {
return
}
const script = document.createElement("script")
script.src = "custom_js.js"
script.async = true
document.body.appendChild(script)
scriptLoaded.current = true
})
return null
}
function CustomCss() {
useEffect(() => {
const link = document.createElement("link")
link.rel = "stylesheet"
link.type = "text/css"
link.href = "custom_css.css"
document.head.appendChild(link)
}, [])
return null
} }
export function App() { export function App() {
useI18n() useI18n()
const root = useAppSelector(state => state.tree.rootCategory)
const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const unreadCount = categoryUnreadCount(root)
useEffect(() => { useEffect(() => {
dispatch(reloadServerInfos()) dispatch(reloadServerInfos())
}, [dispatch]) }, [dispatch])
@@ -211,14 +195,19 @@ export function App() {
return ( return (
<Providers> <Providers>
<> <>
<FaviconHandler /> <UnreadCountTitleHandler unreadCount={unreadCount} enabled={unreadCountTitle} />
<UnreadCountFaviconHandler unreadCount={unreadCount} enabled={unreadCountFavicon} />
<BrowserExtensionBadgeUnreadCountHandler /> <BrowserExtensionBadgeUnreadCountHandler />
<HashRouter> <HashRouter>
<GoogleAnalyticsHandler /> <GoogleAnalyticsHandler />
<RedirectHandler /> <RedirectHandler />
<AppRoutes /> <AppRoutes />
<CustomJs /> <CustomCode />
<CustomCss /> {/* disable pull-to-refresh as it messes with vertical scrolling
safari behaves weirdly when overscroll-behavior is set to none so we disable it only for other browsers
https://github.com/Athou/commafeed/issues/1168
*/}
{!isSafari && <DisablePullToRefresh />}
</HashRouter> </HashRouter>
</> </>
</Providers> </Providers>

View File

@@ -1,7 +1,7 @@
import { createAsyncThunk } from "@reduxjs/toolkit" import { createAsyncThunk } from "@reduxjs/toolkit"
import { type AppDispatch, type RootState } from "app/store" import type { AppDispatch, RootState } from "app/store"
export const createAppAsyncThunk = createAsyncThunk.withTypes<{ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState state: RootState
dispatch: AppDispatch dispatch: AppDispatch
}>() }>()

View File

@@ -1,127 +1,137 @@
import axios, { AxiosError } from "axios" import axios, { type AxiosError } from "axios"
import { import type {
type AddCategoryRequest, AddCategoryRequest,
type AdminSaveUserRequest, AdminSaveUserRequest,
AuthenticationError, AuthenticationError,
type Category, Category,
type CategoryModificationRequest, CategoryModificationRequest,
type CollapseRequest, CollapseRequest,
type Entries, Entries,
type FeedInfo, FeedInfo,
type FeedInfoRequest, FeedInfoRequest,
type FeedModificationRequest, FeedModificationRequest,
type GetEntriesPaginatedRequest, GetEntriesPaginatedRequest,
type IDRequest, IDRequest,
type LoginRequest, LoginRequest,
type MarkRequest, MarkRequest,
type Metrics, Metrics,
type MultipleMarkRequest, MultipleMarkRequest,
type PasswordResetRequest, PasswordResetRequest,
type ProfileModificationRequest, ProfileModificationRequest,
type RegistrationRequest, RegistrationRequest,
type ServerInfo, ServerInfo,
type Settings, Settings,
type StarRequest, StarRequest,
type SubscribeRequest, SubscribeRequest,
type Subscription, Subscription,
type TagRequest, TagRequest,
type UserModel, UserModel,
} from "./types" } from "./types"
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,
error => { error => {
if (isAuthenticationError(error)) { if (isAuthenticationError(error)) {
const data = error.response?.data const data = error.response?.data
window.location.hash = data?.allowRegistrations ? "/welcome" : "/login" window.location.hash = data?.allowRegistrations ? "/welcome" : "/login"
} }
throw error throw error
} }
) )
function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> { function isAuthenticationError(error: unknown): error is AxiosError<AuthenticationError> {
return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status) return axios.isAxiosError(error) && !!error.response && [401, 403].includes(error.response.status)
} }
export const client = { export const client = {
category: { category: {
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),
}, },
entry: { entry: {
mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req), mark: async (req: MarkRequest) => await axiosInstance.post("entry/mark", req),
markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req), markMultiple: async (req: MultipleMarkRequest) => await axiosInstance.post("entry/markMultiple", req),
star: async (req: StarRequest) => await axiosInstance.post("entry/star", req), star: async (req: StarRequest) => await axiosInstance.post("entry/star", req),
getTags: async () => await axiosInstance.get<string[]>("entry/tags"), getTags: async () => await axiosInstance.get<string[]>("entry/tags"),
tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req), tag: async (req: TagRequest) => await axiosInstance.post("entry/tag", req),
}, },
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"),
subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req), subscribe: async (req: SubscribeRequest) => await axiosInstance.post<number>("feed/subscribe", req),
unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req), unsubscribe: async (req: IDRequest) => await axiosInstance.post("feed/unsubscribe", req),
importOpml: async (req: File) => { importOpml: async (req: File) => {
const formData = new FormData() const formData = new FormData()
formData.append("file", req) formData.append("file", req)
return await axiosInstance.post("feed/import", formData, { return await axiosInstance.post("feed/import", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
}) })
}, },
}, },
user: { user: {
login: async (req: LoginRequest) => await axiosInstance.post("user/login", req), login: async (req: LoginRequest) => {
register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req), const formData = new URLSearchParams()
passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req), formData.append("j_username", req.name)
getSettings: async () => await axiosInstance.get<Settings>("user/settings"), formData.append("j_password", req.password)
saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings), return await axiosInstance.post("j_security_check", formData, {
getProfile: async () => await axiosInstance.get<UserModel>("user/profile"), baseURL: ".",
saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req), headers: {
deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"), "Content-Type": "application/x-www-form-urlencoded",
}, },
server: { })
getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"), },
}, register: async (req: RegistrationRequest) => await axiosInstance.post("user/register", req),
admin: { passwordReset: async (req: PasswordResetRequest) => await axiosInstance.post("user/passwordReset", req),
getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"), getSettings: async () => await axiosInstance.get<Settings>("user/settings"),
saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req), saveSettings: async (settings: Settings) => await axiosInstance.post("user/settings", settings),
deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req), getProfile: async () => await axiosInstance.get<UserModel>("user/profile"),
getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"), saveProfile: async (req: ProfileModificationRequest) => await axiosInstance.post("user/profile", req),
}, deleteProfile: async () => await axiosInstance.post("user/profile/deleteAccount"),
} },
server: {
/** getServerInfos: async () => await axiosInstance.get<ServerInfo>("server/get"),
* transform an error object to an array of strings that can be displayed to the user },
* @param err an error object (e.g. from axios) admin: {
* @returns an array of messages to show the user getAllUsers: async () => await axiosInstance.get<UserModel[]>("admin/user/getAll"),
*/ saveUser: async (req: AdminSaveUserRequest) => await axiosInstance.post("admin/user/save", req),
export const errorToStrings = (err: unknown) => { deleteUser: async (req: IDRequest) => await axiosInstance.post("admin/user/delete", req),
let strings: string[] = [] getMetrics: async () => await axiosInstance.get<Metrics>("admin/metrics"),
},
if (axios.isAxiosError(err) && err.response) { }
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] * transform an error object to an array of strings that can be displayed to the user
} * @param err an error object (e.g. from axios)
* @returns an array of messages to show the user
return strings */
} export const errorToStrings = (err: unknown) => {
let strings: 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 if (axios.isAxiosError(err) && err.response) {
} if (typeof err.response.data === "string") strings.push(err.response.data)
if (isMessageError(err)) strings.push(err.response.data.message)
function isMessageArrayError(err: AxiosError): err is AxiosError<{ errors: string[] }> { if (isMessageArrayError(err)) strings = [...strings, ...err.response.data.errors]
return !!err.response && !!err.response.data && typeof err.response.data === "object" && "errors" in err.response.data }
}
return strings
}
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
}
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
}

View File

@@ -1,109 +1,112 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { type IconType } from "react-icons" import type { IconType } from "react-icons"
import { FaAt } from "react-icons/fa" import { FaAt } from "react-icons/fa"
import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiTwitter } from "react-icons/si" import { SiBuffer, SiFacebook, SiGmail, SiInstapaper, SiPocket, SiTumblr, SiX } from "react-icons/si"
import { type Category, type Entry, type SharingSettings } from "./types" import type { Category, Entry, SharingSettings } from "./types"
const categories: Record<string, Category> = { const categories: Record<string, Category> = {
all: { all: {
id: "all", id: "all",
name: t`All`, name: t`All`,
expanded: false, expanded: false,
children: [], children: [],
feeds: [], feeds: [],
position: 0, position: 0,
}, },
starred: { starred: {
id: "starred", id: "starred",
name: t`Starred`, name: t`Starred`,
expanded: false, expanded: false,
children: [], children: [],
feeds: [], feeds: [],
position: 1, position: 1,
}, },
} }
const sharing: { const sharing: {
[key in keyof SharingSettings]: { [key in keyof SharingSettings]: {
label: string label: string
icon: IconType icon: IconType
color: `#${string}` color: `#${string}`
url: (url: string, description: string) => string url: (url: string, description: string) => string
} }
} = { } = {
email: { email: {
label: "Email", label: "Email",
icon: FaAt, icon: FaAt,
color: "#000000", color: "#000000",
url: (url, desc) => `mailto:?subject=${desc}&body=${url}`, url: (url, desc) => `mailto:?subject=${desc}&body=${url}`,
}, },
gmail: { gmail: {
label: "Gmail", label: "Gmail",
icon: SiGmail, icon: SiGmail,
color: "#EA4335", color: "#EA4335",
url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`, url: (url, desc) => `https://mail.google.com/mail/?view=cm&fs=1&tf=1&source=mailto&su=${desc}&body=${url}`,
}, },
facebook: { facebook: {
label: "Facebook", label: "Facebook",
icon: SiFacebook, icon: SiFacebook,
color: "#1B74E4", color: "#1B74E4",
url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`, url: url => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
}, },
twitter: { twitter: {
label: "Twitter", label: "X",
icon: SiTwitter, icon: SiX,
color: "#1D9BF0", color: "#000000",
url: (url, desc) => `https://twitter.com/share?text=${desc}&url=${url}`, url: (url, desc) => `https://x.com/share?text=${desc}&url=${url}`,
}, },
tumblr: { tumblr: {
label: "Tumblr", label: "Tumblr",
icon: SiTumblr, icon: SiTumblr,
color: "#375672", color: "#375672",
url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`, url: (url, desc) => `https://www.tumblr.com/share/link?url=${url}&name=${desc}`,
}, },
pocket: { pocket: {
label: "Pocket", label: "Pocket",
icon: SiPocket, icon: SiPocket,
color: "#EF4154", color: "#EF4154",
url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`, url: (url, desc) => `https://getpocket.com/save?url=${url}&title=${desc}`,
}, },
instapaper: { instapaper: {
label: "Instapaper", label: "Instapaper",
icon: SiInstapaper, icon: SiInstapaper,
color: "#010101", color: "#010101",
url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`, url: (url, desc) => `https://www.instapaper.com/hello2?url=${url}&title=${desc}`,
}, },
buffer: { buffer: {
label: "Buffer", label: "Buffer",
icon: SiBuffer, icon: SiBuffer,
color: "#000000", color: "#000000",
url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`, url: (url, desc) => `https://bufferapp.com/add?url=${url}&text=${desc}`,
}, },
} }
export const Constants = { export const Constants = {
categories, categories,
sharing, sharing,
layout: { layout: {
mobileBreakpoint: 992, mobileBreakpoint: 992,
mobileBreakpointName: "md", mobileBreakpointName: "md",
headerHeight: 60, headerHeight: 60,
entryMaxWidth: 650, entryMaxWidth: 650,
isTopVisible: (div: HTMLElement) => { isTopVisible: (div: HTMLElement) => {
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect() const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
return div.getBoundingClientRect().top >= (header?.bottom ?? 0) return div.getBoundingClientRect().top >= (header?.bottom ?? 0)
}, },
isBottomVisible: (div: HTMLElement) => { isBottomVisible: (div: HTMLElement) => {
const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect() const footer = document.getElementById(Constants.dom.footerId)?.getBoundingClientRect()
return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight) return div.getBoundingClientRect().bottom <= (footer?.top ?? window.innerHeight)
}, },
}, },
dom: { dom: {
headerId: "header", headerId: "header",
footerId: "footer", footerId: "footer",
entryId: (entry: Entry) => `entry-id-${entry.id}`, entryId: (entry: Entry) => `entry-id-${entry.id}`,
entryContextMenuId: (entry: Entry) => entry.id, entryContextMenuId: (entry: Entry) => entry.id,
}, },
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension", tooltip: {
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e", delay: 500,
} },
browserExtensionUrl: "https://github.com/Athou/commafeed-browser-extension",
bitcoinWalletAddress: "1dymfUxqCWpyD7a6rQSqNy4rLVDBsAr5e",
}

View File

@@ -1,145 +1,145 @@
import { configureStore } from "@reduxjs/toolkit" import { configureStore } from "@reduxjs/toolkit"
import { type client } from "app/client" import type { client } from "app/client"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks" import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "app/entries/thunks"
import { reducers, type RootState } from "app/store" import { type RootState, reducers } from "app/store"
import { type Entries, type Entry } from "app/types" import type { Entries, Entry } from "app/types"
import { type AxiosResponse } from "axios" import type { AxiosResponse } from "axios"
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import { mockReset } from "vitest-mock-extended" import { any, mockReset } from "vitest-mock-extended"
const mockClient = await vi.hoisted(async () => { const mockClient = await vi.hoisted(async () => {
const mockModule = await import("vitest-mock-extended") const mockModule = await import("vitest-mock-extended")
return mockModule.mockDeep<typeof client>() return mockModule.mockDeep<typeof client>()
}) })
vi.mock("app/client", () => ({ client: mockClient })) vi.mock("app/client", () => ({ client: mockClient }))
describe("entries", () => { describe("entries", () => {
beforeEach(() => { beforeEach(() => {
mockReset(mockClient) mockReset(mockClient)
}) })
it("loads entries", async () => { it("loads entries", async () => {
mockClient.feed.getEntries.mockResolvedValue({ mockClient.feed.getEntries.calledWith(any()).mockResolvedValue({
data: { data: {
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: false, hasMore: false,
name: "my-feed", name: "my-feed",
errorCount: 3, errorCount: 3,
feedLink: "https://mysite.com/feed", feedLink: "https://mysite.com/feed",
timestamp: 123, timestamp: 123,
ignoredReadStatus: false, ignoredReadStatus: false,
}, },
} as AxiosResponse<Entries>) } as AxiosResponse<Entries>)
const store = configureStore({ reducer: reducers }) const store = configureStore({ reducer: reducers })
const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true })) const promise = store.dispatch(loadEntries({ source: { type: "feed", id: "feed-id" }, clearSearch: true }))
expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id") expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([]) expect(store.getState().entries.entries).toStrictEqual([])
expect(store.getState().entries.hasMore).toBe(true) expect(store.getState().entries.hasMore).toBe(true)
expect(store.getState().entries.sourceLabel).toBe("") expect(store.getState().entries.sourceLabel).toBe("")
expect(store.getState().entries.sourceWebsiteUrl).toBe("") expect(store.getState().entries.sourceWebsiteUrl).toBe("")
expect(store.getState().entries.timestamp).toBeUndefined() expect(store.getState().entries.timestamp).toBeUndefined()
await promise await promise
expect(store.getState().entries.source.type).toBe("feed") expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id") expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }]) expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
expect(store.getState().entries.hasMore).toBe(false) expect(store.getState().entries.hasMore).toBe(false)
expect(store.getState().entries.sourceLabel).toBe("my-feed") expect(store.getState().entries.sourceLabel).toBe("my-feed")
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed") expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
expect(store.getState().entries.timestamp).toBe(123) expect(store.getState().entries.timestamp).toBe(123)
}) })
it("loads more entries", async () => { it("loads more entries", async () => {
mockClient.category.getEntries.mockResolvedValue({ mockClient.category.getEntries.calledWith(any()).mockResolvedValue({
data: { data: {
entries: [{ id: "4" } as Entry], entries: [{ id: "4" } as Entry],
hasMore: false, hasMore: false,
name: "my-feed", name: "my-feed",
errorCount: 3, errorCount: 3,
feedLink: "https://mysite.com/feed", feedLink: "https://mysite.com/feed",
timestamp: 123, timestamp: 123,
ignoredReadStatus: false, ignoredReadStatus: false,
}, },
} as AxiosResponse<Entries>) } as AxiosResponse<Entries>)
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3" } as Entry], entries: [{ id: "3" } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
const promise = store.dispatch(loadMoreEntries()) const promise = store.dispatch(loadMoreEntries())
await promise await promise
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }]) expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
expect(store.getState().entries.hasMore).toBe(false) expect(store.getState().entries.hasMore).toBe(false)
}) })
it("marks an entry as read", () => { it("marks an entry as read", () => {
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true })) store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
expect(store.getState().entries.entries).toStrictEqual([ expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true }, { id: "3", read: true },
{ id: "4", read: false }, { id: "4", read: false },
]) ])
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true }) expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
}) })
it("marks all entries as read", () => { it("marks all entries as read", () => {
const store = configureStore({ const store = configureStore({
reducer: reducers, reducer: reducers,
preloadedState: { preloadedState: {
entries: { entries: {
source: { source: {
type: "category", type: "category",
id: "category-id", id: "category-id",
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry], entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
}, },
} as RootState, } as RootState,
}) })
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } })) store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
expect(store.getState().entries.entries).toStrictEqual([ expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true }, { id: "3", read: true },
{ id: "4", read: true }, { id: "4", read: true },
]) ])
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true }) expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
}) })
}) })

View File

@@ -1,134 +1,122 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } 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"
export interface EntrySource { export interface EntrySource {
type: EntrySourceType type: EntrySourceType
id: string id: string
} }
export type ExpendableEntry = Entry & { expanded?: boolean } export type ExpendableEntry = Entry & { expanded?: boolean }
interface EntriesState { interface EntriesState {
/** selected source */ /** selected source */
source: EntrySource source: EntrySource
sourceLabel: string sourceLabel: string
sourceWebsiteUrl: string sourceWebsiteUrl: string
entries: ExpendableEntry[] entries: ExpendableEntry[]
/** stores when the first batch of entries were retrieved /** stores when the first batch of entries were retrieved
* *
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown * this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
*/ */
timestamp?: number timestamp?: number
selectedEntryId?: string selectedEntryId?: string
hasMore: boolean hasMore: boolean
loading: boolean loading: boolean
search?: string search?: string
scrollingToEntry: boolean scrollingToEntry: boolean
} }
const initialState: EntriesState = { const initialState: EntriesState = {
source: { source: {
type: "category", type: "category",
id: Constants.categories.all.id, id: Constants.categories.all.id,
}, },
sourceLabel: "", sourceLabel: "",
sourceWebsiteUrl: "", sourceWebsiteUrl: "",
entries: [], entries: [],
hasMore: true, hasMore: true,
loading: false, loading: false,
scrollingToEntry: false, scrollingToEntry: false,
} }
export const entriesSlice = createSlice({ export const entriesSlice = createSlice({
name: "entries", name: "entries",
initialState, initialState,
reducers: { reducers: {
setSelectedEntry: (state, action: PayloadAction<Entry>) => { setSelectedEntry: (state, action: PayloadAction<Entry>) => {
state.selectedEntryId = action.payload.id state.selectedEntryId = action.payload.id
}, },
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => { setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
state.entries for (const e of state.entries.filter(e => e.id === action.payload.entry.id)) {
.filter(e => e.id === action.payload.entry.id) e.expanded = action.payload.expanded
.forEach(e => { }
e.expanded = action.payload.expanded },
}) setScrollingToEntry: (state, action: PayloadAction<boolean>) => {
}, state.scrollingToEntry = action.payload
setScrollingToEntry: (state, action: PayloadAction<boolean>) => { },
state.scrollingToEntry = action.payload setSearch: (state, action: PayloadAction<string>) => {
}, state.search = action.payload
setSearch: (state, action: PayloadAction<string>) => { },
state.search = action.payload },
}, extraReducers: builder => {
}, builder.addCase(markEntry.pending, (state, action) => {
extraReducers: builder => { for (const e of state.entries.filter(e => e.id === action.meta.arg.entry.id)) {
builder.addCase(markEntry.pending, (state, action) => { e.read = action.meta.arg.read
state.entries }
.filter(e => e.id === action.meta.arg.entry.id) })
.forEach(e => { builder.addCase(markMultipleEntries.pending, (state, action) => {
e.read = action.meta.arg.read for (const e of state.entries.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id))) {
}) e.read = action.meta.arg.read
}) }
builder.addCase(markMultipleEntries.pending, (state, action) => { })
state.entries builder.addCase(markAllEntries.pending, (state, action) => {
.filter(e => action.meta.arg.entries.some(e2 => e2.id === e.id)) for (const e of state.entries.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true))) {
.forEach(e => { e.read = true
e.read = action.meta.arg.read }
}) })
}) builder.addCase(starEntry.pending, (state, action) => {
builder.addCase(markAllEntries.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)) {
state.entries e.starred = action.meta.arg.starred
.filter(e => (action.meta.arg.req.olderThan ? e.date < action.meta.arg.req.olderThan : true)) }
.forEach(e => { })
e.read = true builder.addCase(loadEntries.pending, (state, action) => {
}) state.source = action.meta.arg.source
}) state.entries = []
builder.addCase(starEntry.pending, (state, action) => { state.timestamp = undefined
state.entries state.sourceLabel = ""
.filter(e => action.meta.arg.entry.id === e.id && action.meta.arg.entry.feedId === e.feedId) state.sourceWebsiteUrl = ""
.forEach(e => { state.hasMore = true
e.starred = action.meta.arg.starred state.selectedEntryId = undefined
}) state.loading = true
}) })
builder.addCase(loadEntries.pending, (state, action) => { builder.addCase(loadMoreEntries.pending, state => {
state.source = action.meta.arg.source state.loading = true
state.entries = [] })
state.timestamp = undefined builder.addCase(loadEntries.fulfilled, (state, action) => {
state.sourceLabel = "" state.entries = action.payload.entries
state.sourceWebsiteUrl = "" state.timestamp = action.payload.timestamp
state.hasMore = true state.sourceLabel = action.payload.name
state.selectedEntryId = undefined state.sourceWebsiteUrl = action.payload.feedLink
state.loading = true state.hasMore = action.payload.hasMore
}) state.loading = false
builder.addCase(loadMoreEntries.pending, state => { })
state.loading = true builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
}) // remove already existing entries
builder.addCase(loadEntries.fulfilled, (state, action) => { const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
state.entries = action.payload.entries state.entries = [...state.entries, ...entriesToAdd]
state.timestamp = action.payload.timestamp state.hasMore = action.payload.hasMore
state.sourceLabel = action.payload.name state.loading = false
state.sourceWebsiteUrl = action.payload.feedLink })
state.hasMore = action.payload.hasMore builder.addCase(tagEntry.pending, (state, action) => {
state.loading = false for (const e of state.entries.filter(e => +e.id === action.meta.arg.entryId)) {
}) e.tags = action.meta.arg.tags
builder.addCase(loadMoreEntries.fulfilled, (state, action) => { }
// remove already existing entries })
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id)) },
state.entries = [...state.entries, ...entriesToAdd] })
state.hasMore = action.payload.hasMore
state.loading = false export const { setSearch } = entriesSlice.actions
})
builder.addCase(tagEntry.pending, (state, action) => {
state.entries
.filter(e => +e.id === action.meta.arg.entryId)
.forEach(e => {
e.tags = action.meta.arg.tags
})
})
},
})
export const { setSearch } = entriesSlice.actions

View File

@@ -1,241 +1,267 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { entriesSlice, type EntrySource, type EntrySourceType, setSearch } from "app/entries/slice" import { type EntrySource, type EntrySourceType, entriesSlice, setSearch } from "app/entries/slice"
import type { RootState } from "app/store" import type { RootState } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import type { Entry, MarkRequest, TagRequest } from "app/types" import type { Entry, MarkRequest, TagRequest } from "app/types"
import { reloadTags } from "app/user/thunks" import { reloadTags } from "app/user/thunks"
import { scrollToWithCallback } from "app/utils" import { scrollToWithCallback } from "app/utils"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
const getEndpoint = (sourceType: EntrySourceType) => const getEndpoint = (sourceType: EntrySourceType) =>
sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries sourceType === "category" || sourceType === "tag" ? client.category.getEntries : client.feed.getEntries
export const loadEntries = createAppAsyncThunk( export const loadEntries = createAppAsyncThunk(
"entries/load", "entries/load",
async ( async (
arg: { arg: {
source: EntrySource source: EntrySource
clearSearch: boolean clearSearch: boolean
}, },
thunkApi thunkApi
) => { ) => {
if (arg.clearSearch) thunkApi.dispatch(setSearch("")) if (arg.clearSearch) thunkApi.dispatch(setSearch(""))
const state = thunkApi.getState() const state = thunkApi.getState()
const endpoint = getEndpoint(arg.source.type) const endpoint = getEndpoint(arg.source.type)
const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0)) const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0))
return result.data return result.data
} }
) )
export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => { export const loadMoreEntries = createAppAsyncThunk("entries/loadMore", async (_, thunkApi) => {
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" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type) const endpoint = getEndpoint(state.entries.source.type)
const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset)) const result = await endpoint(buildGetEntriesPaginatedRequest(state, source, offset))
return result.data return result.data
}) })
const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({ const buildGetEntriesPaginatedRequest = (state: RootState, source: EntrySource, offset: number) => ({
id: source.type === "tag" ? Constants.categories.all.id : source.id, id: source.type === "tag" ? Constants.categories.all.id : source.id,
order: state.user.settings?.readingOrder, order: state.user.settings?.readingOrder,
readType: state.user.settings?.readingMode, readType: state.entries.search ? "all" : state.user.settings?.readingMode,
offset, offset,
limit: 50, limit: 50,
tag: source.type === "tag" ? source.id : undefined, tag: source.type === "tag" ? source.id : undefined,
keywords: state.entries.search, keywords: state.entries.search,
}) })
export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => { export const reloadEntries = createAppAsyncThunk("entries/reload", (arg, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
}) })
export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => { export const search = createAppAsyncThunk("entries/search", (arg: string, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
thunkApi.dispatch(setSearch(arg)) thunkApi.dispatch(setSearch(arg))
thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false })) thunkApi.dispatch(loadEntries({ source: state.entries.source, clearSearch: false }))
}) })
export const markEntry = createAppAsyncThunk( export const markEntry = createAppAsyncThunk(
"entries/entry/mark", "entries/entry/mark",
(arg: { entry: Entry; read: boolean }) => { (arg: { entry: Entry; read: boolean }) => {
client.entry.mark({ client.entry.mark({
id: arg.entry.id, id: arg.entry.id,
read: arg.read, read: arg.read,
}) })
}, },
{ {
condition: arg => arg.entry.read !== arg.read, condition: arg => arg.entry.markable && arg.entry.read !== arg.read,
} }
) )
export const markMultipleEntries = createAppAsyncThunk( export const markMultipleEntries = createAppAsyncThunk(
"entries/entry/markMultiple", "entries/entry/markMultiple",
async ( async (
arg: { arg: {
entries: Entry[] entries: Entry[]
read: boolean read: boolean
}, },
thunkApi thunkApi
) => { ) => {
const requests: MarkRequest[] = arg.entries.map(e => ({ const requests: MarkRequest[] = arg.entries.map(e => ({
id: e.id, id: e.id,
read: arg.read, read: arg.read,
})) }))
await client.entry.markMultiple({ requests }) await client.entry.markMultiple({ requests })
thunkApi.dispatch(reloadTree()) thunkApi.dispatch(reloadTree())
} }
) )
export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => { export const markEntriesUpToEntry = createAppAsyncThunk("entries/entry/upToEntry", (arg: Entry, thunkApi) => {
const state = thunkApi.getState() const state = thunkApi.getState()
const { entries } = state.entries const { entries } = state.entries
const index = entries.findIndex(e => e.id === arg.id) const index = entries.findIndex(e => e.id === arg.id)
if (index === -1) return if (index === -1) return
thunkApi.dispatch( thunkApi.dispatch(
markMultipleEntries({ markMultipleEntries({
entries: entries.slice(0, index + 1), entries: entries.slice(0, index + 1),
read: true, read: true,
}) })
) )
}) })
export const markAllEntries = createAppAsyncThunk( export const markAllEntries = createAppAsyncThunk(
"entries/entry/markAll", "entries/entry/markAll",
async ( async (
arg: { arg: {
sourceType: EntrySourceType sourceType: EntrySourceType
req: MarkRequest req: MarkRequest
}, },
thunkApi thunkApi
) => { ) => {
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
await endpoint(arg.req) await endpoint(arg.req)
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
thunkApi.dispatch(reloadTree()) thunkApi.dispatch(reloadTree())
} }
) )
export const starEntry = createAppAsyncThunk("entries/entry/star", (arg: { entry: Entry; starred: boolean }) => { export const starEntry = createAppAsyncThunk(
client.entry.star({ "entries/entry/star",
id: arg.entry.id, (arg: { entry: Entry; starred: boolean }) => {
feedId: +arg.entry.feedId, client.entry.star({
starred: arg.starred, id: arg.entry.id,
}) feedId: +arg.entry.feedId,
}) starred: arg.starred,
export const selectEntry = createAppAsyncThunk( })
"entries/entry/select", },
( {
arg: { condition: arg => arg.entry.markable && arg.entry.starred !== arg.starred,
entry: Entry }
expand: boolean )
markAsRead: boolean export const selectEntry = createAppAsyncThunk(
scrollToEntry: boolean "entries/entry/select",
}, (
thunkApi arg: {
) => { entry: Entry
const state = thunkApi.getState() expand: boolean
const entry = state.entries.entries.find(e => e.id === arg.entry.id) markAsRead: boolean
if (!entry) return scrollToEntry: boolean
},
// flushSync is required because we need the newly selected entry to be expanded thunkApi
// and the previously selected entry to be collapsed to be able to scroll to the right position ) => {
flushSync(() => { const state = thunkApi.getState()
// mark as read if requested const entry = state.entries.entries.find(e => e.id === arg.entry.id)
if (arg.markAsRead) { if (!entry) return
thunkApi.dispatch(markEntry({ entry, read: true }))
} // flushSync is required because we need the newly selected entry to be expanded
// and the previously selected entry to be collapsed to be able to scroll to the right position
// set entry as selected flushSync(() => {
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry)) // mark as read if requested
if (arg.markAsRead) {
// expand if requested thunkApi.dispatch(markEntry({ entry, read: true }))
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId) }
if (previouslySelectedEntry) {
thunkApi.dispatch( // set entry as selected
entriesSlice.actions.setEntryExpanded({ thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
entry: previouslySelectedEntry,
expanded: false, // expand if requested
}) const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
) if (previouslySelectedEntry) {
} thunkApi.dispatch(
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand })) entriesSlice.actions.setEntryExpanded({
}) entry: previouslySelectedEntry,
expanded: false,
if (arg.scrollToEntry) { })
const entryElement = document.getElementById(Constants.dom.entryId(entry)) )
if (entryElement) { }
const scrollMode = state.user.settings?.scrollMode thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: arg.expand }))
const entryEntirelyVisible = Constants.layout.isTopVisible(entryElement) && Constants.layout.isBottomVisible(entryElement) })
if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
const scrollSpeed = state.user.settings?.scrollSpeed if (arg.scrollToEntry) {
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true)) const viewMode = state.user.localSettings.viewMode
scrollToEntry(entryElement, scrollSpeed, () => thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false)))
} const entryIndex = state.entries.entries.indexOf(entry)
} const entriesToKeepOnTopWhenScrolling =
} viewMode === "expanded" ? 0 : Math.min(state.user.settings?.entriesToKeepOnTopWhenScrolling ?? 0, entryIndex)
} const entryToScrollTo = state.entries.entries[entryIndex - entriesToKeepOnTopWhenScrolling]
)
const scrollToEntry = (entryElement: HTMLElement, scrollSpeed: number | undefined, onScrollEnded: () => void) => { const entryElement = document.getElementById(Constants.dom.entryId(entry))
const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect() const entryElementToScrollTo = document.getElementById(Constants.dom.entryId(entryToScrollTo))
const offset = (header?.bottom ?? 0) + 3 if (entryElement && entryElementToScrollTo) {
scrollToWithCallback({ const scrollMode = state.user.settings?.scrollMode
options: { const entryEntirelyVisible =
top: entryElement.offsetTop - offset, Constants.layout.isTopVisible(entryElementToScrollTo) && Constants.layout.isBottomVisible(entryElement)
behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto", if (scrollMode === "always" || (scrollMode === "if_needed" && !entryEntirelyVisible)) {
}, const scrollSpeed = state.user.settings?.scrollSpeed
onScrollEnded, const margin = viewMode === "detailed" ? 8 : 3
}) thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(true))
} scrollToEntry(entryElementToScrollTo, margin, scrollSpeed, () =>
thunkApi.dispatch(entriesSlice.actions.setScrollingToEntry(false))
export const selectPreviousEntry = createAppAsyncThunk( )
"entries/entry/selectPrevious", }
( }
arg: { }
expand: boolean }
markAsRead: boolean )
scrollToEntry: boolean const scrollToEntry = (entryElement: HTMLElement, margin: number, scrollSpeed: number | undefined, onScrollEnded: () => void) => {
}, const header = document.getElementById(Constants.dom.headerId)?.getBoundingClientRect()
thunkApi const offset = (header?.bottom ?? 0) + margin
) => { scrollToWithCallback({
const state = thunkApi.getState() options: {
const { entries } = state.entries top: entryElement.offsetTop - offset,
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1 behavior: scrollSpeed && scrollSpeed > 0 ? "smooth" : "auto",
if (previousIndex >= 0) { },
thunkApi.dispatch( onScrollEnded,
selectEntry({ })
entry: entries[previousIndex], }
expand: arg.expand,
markAsRead: arg.markAsRead, export const selectPreviousEntry = createAppAsyncThunk(
scrollToEntry: arg.scrollToEntry, "entries/entry/selectPrevious",
}) (
) arg: {
} expand: boolean
} markAsRead: boolean
) scrollToEntry: boolean
export const selectNextEntry = createAppAsyncThunk( },
"entries/entry/selectNext", thunkApi
( ) => {
arg: { const state = thunkApi.getState()
expand: boolean const { entries } = state.entries
markAsRead: boolean const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
scrollToEntry: boolean if (previousIndex >= 0) {
}, thunkApi.dispatch(
thunkApi selectEntry({
) => { entry: entries[previousIndex],
const state = thunkApi.getState() expand: arg.expand,
const { entries } = state.entries markAsRead: arg.markAsRead,
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1 scrollToEntry: arg.scrollToEntry,
if (nextIndex < entries.length) { })
thunkApi.dispatch( )
selectEntry({ }
entry: entries[nextIndex], }
expand: arg.expand, )
markAsRead: arg.markAsRead, export const selectNextEntry = createAppAsyncThunk(
scrollToEntry: arg.scrollToEntry, "entries/entry/selectNext",
}) async (
) arg: {
} expand: boolean
} markAsRead: boolean
) scrollToEntry: boolean
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => { },
await client.entry.tag(arg) thunkApi
thunkApi.dispatch(reloadTags()) ) => {
}) const state = thunkApi.getState()
const { entries, hasMore, loading } = state.entries
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
// load more entries if needed
// this can happen if the last entry is too large to fit on the screen and the infinite loader doesn't trigger
if (nextIndex >= entries.length && hasMore && !loading) {
await thunkApi.dispatch(loadMoreEntries())
}
const entriesAfterLoading = thunkApi.getState().entries.entries
if (nextIndex < entriesAfterLoading.length) {
thunkApi.dispatch(
selectEntry({
entry: entriesAfterLoading[nextIndex],
expand: arg.expand,
markAsRead: arg.markAsRead,
scrollToEntry: arg.scrollToEntry,
})
)
}
}
)
export const tagEntry = createAppAsyncThunk("entries/entry/tag", async (arg: TagRequest, thunkApi) => {
await client.entry.tag(arg)
thunkApi.dispatch(reloadTags())
})

View File

@@ -1,10 +1,10 @@
import { redirectToCategory } from "app/redirect/thunks" import { redirectToCategory } from "app/redirect/thunks"
import { store } from "app/store" import { store } from "app/store"
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
describe("redirects", () => { describe("redirects", () => {
it("redirects to category", async () => { it("redirects to category", async () => {
await store.dispatch(redirectToCategory("1")) await store.dispatch(redirectToCategory("1"))
expect(store.getState().redirect.to).toBe("/app/category/1") expect(store.getState().redirect.to).toBe("/app/category/1")
}) })
}) })

View File

@@ -1,19 +1,19 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
interface RedirectState { interface RedirectState {
to?: string to?: string
} }
const initialState: RedirectState = {} const initialState: RedirectState = {}
export const redirectSlice = createSlice({ export const redirectSlice = createSlice({
name: "redirect", name: "redirect",
initialState, initialState,
reducers: { reducers: {
redirectTo: (state, action: PayloadAction<string | undefined>) => { redirectTo: (state, action: PayloadAction<string | undefined>) => {
state.to = action.payload state.to = action.payload
}, },
}, },
}) })
export const { redirectTo } = redirectSlice.actions export const { redirectTo } = redirectSlice.actions

View File

@@ -1,45 +1,45 @@
import { createAppAsyncThunk } from "app/async-thunk" 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 redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) => export const redirectToPasswordRecovery = createAppAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/passwordRecovery")) thunkApi.dispatch(redirectTo("/passwordRecovery"))
) )
export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api"))) export const redirectToApiDocumentation = createAppAsyncThunk("redirect/api", (_, thunkApi) => thunkApi.dispatch(redirectTo("/api")))
export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => { export const redirectToSelectedSource = createAppAsyncThunk("redirect/selectedSource", (_, thunkApi) => {
const { source } = thunkApi.getState().entries const { source } = thunkApi.getState().entries
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`)) thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
}) })
export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) => export const redirectToCategory = createAppAsyncThunk("redirect/category", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}`)) thunkApi.dispatch(redirectTo(`/app/category/${id}`))
) )
export const redirectToRootCategory = createAppAsyncThunk( export const redirectToRootCategory = createAppAsyncThunk(
"redirect/category/root", "redirect/category/root",
async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id)) async (_, thunkApi) => await thunkApi.dispatch(redirectToCategory(Constants.categories.all.id))
) )
export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) => export const redirectToCategoryDetails = createAppAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`)) thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
) )
export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) => export const redirectToFeed = createAppAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/feed/${id}`)) thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
) )
export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) => export const redirectToFeedDetails = createAppAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
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"))
) )
export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) => export const redirectToMetrics = createAppAsyncThunk("redirect/admin/metrics", (_, thunkApi) =>
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")))

View File

@@ -1,29 +1,29 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
import { reloadServerInfos } from "app/server/thunks" import { reloadServerInfos } from "app/server/thunks"
import { type ServerInfo } from "app/types" import type { ServerInfo } from "app/types"
interface ServerState { interface ServerState {
serverInfos?: ServerInfo serverInfos?: ServerInfo
webSocketConnected: boolean webSocketConnected: boolean
} }
const initialState: ServerState = { const initialState: ServerState = {
webSocketConnected: false, webSocketConnected: false,
} }
export const serverSlice = createSlice({ export const serverSlice = createSlice({
name: "server", name: "server",
initialState, initialState,
reducers: { reducers: {
setWebSocketConnected: (state, action: PayloadAction<boolean>) => { setWebSocketConnected: (state, action: PayloadAction<boolean>) => {
state.webSocketConnected = action.payload state.webSocketConnected = action.payload
}, },
}, },
extraReducers: builder => { extraReducers: builder => {
builder.addCase(reloadServerInfos.fulfilled, (state, action) => { builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
state.serverInfos = action.payload state.serverInfos = action.payload
}) })
}, },
}) })
export const { setWebSocketConnected } = serverSlice.actions export const { setWebSocketConnected } = serverSlice.actions

View File

@@ -1,4 +1,4 @@
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))

View File

@@ -1,23 +1,53 @@
import { configureStore } from "@reduxjs/toolkit" import { configureStore } from "@reduxjs/toolkit"
import { entriesSlice } from "app/entries/slice" import { entriesSlice } from "app/entries/slice"
import { redirectSlice } from "app/redirect/slice" import { redirectSlice } from "app/redirect/slice"
import { serverSlice } from "app/server/slice" import { serverSlice } from "app/server/slice"
import { treeSlice } from "app/tree/slice" import { treeSlice } from "app/tree/slice"
import { userSlice } from "app/user/slice" import type { LocalSettings } from "app/types"
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux" import { initialLocalSettings, userSlice } from "app/user/slice"
import { type TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
export const reducers = {
entries: entriesSlice.reducer, export const reducers = {
redirect: redirectSlice.reducer, entries: entriesSlice.reducer,
tree: treeSlice.reducer, redirect: redirectSlice.reducer,
server: serverSlice.reducer, tree: treeSlice.reducer,
user: userSlice.reducer, server: serverSlice.reducer,
} user: userSlice.reducer,
}
export const store = configureStore({ reducer: reducers })
const loadLocalSettings = (): LocalSettings => {
export type RootState = ReturnType<typeof store.getState> const json = localStorage.getItem("commafeed-local-settings")
export type AppDispatch = typeof store.dispatch if (json) {
return JSON.parse(json)
export const useAppDispatch: () => AppDispatch = useDispatch }
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// load old settings
const viewMode = localStorage.getItem("view-mode")
const sidebarWidth = localStorage.getItem("sidebar-width")
const announcementHash = localStorage.getItem("announcement-hash")
return {
...initialLocalSettings,
viewMode: viewMode ? JSON.parse(viewMode) : initialLocalSettings.viewMode,
sidebarWidth: sidebarWidth ? JSON.parse(sidebarWidth) : initialLocalSettings.sidebarWidth,
announcementHash: announcementHash ? JSON.parse(announcementHash) : initialLocalSettings.announcementHash,
}
}
export const store = configureStore({
reducer: reducers,
preloadedState: {
user: {
localSettings: loadLocalSettings(),
},
},
})
store.subscribe(() => {
const localSettings = store.getState().user.localSettings
localStorage.setItem("commafeed-local-settings", JSON.stringify(localSettings))
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

View File

@@ -1,72 +1,68 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit" import { type PayloadAction, createSlice } from "@reduxjs/toolkit"
import { markEntry } from "app/entries/thunks" import { markEntry } from "app/entries/thunks"
import { redirectTo } from "app/redirect/slice" import { redirectTo } from "app/redirect/slice"
import { collapseTreeCategory, reloadTree } from "app/tree/thunks" import { collapseTreeCategory, reloadTree } from "app/tree/thunks"
import { type Category } from "app/types" import type { Category } from "app/types"
import { visitCategoryTree } from "app/utils" import { visitCategoryTree } from "app/utils"
interface TreeState { interface TreeState {
rootCategory?: Category rootCategory?: Category
mobileMenuOpen: boolean mobileMenuOpen: boolean
sidebarVisible: boolean sidebarVisible: boolean
} }
const initialState: TreeState = { const initialState: TreeState = {
mobileMenuOpen: false, mobileMenuOpen: false,
sidebarVisible: true, sidebarVisible: true,
} }
export const treeSlice = createSlice({ export const treeSlice = createSlice({
name: "tree", name: "tree",
initialState, initialState,
reducers: { reducers: {
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => { setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
state.mobileMenuOpen = action.payload state.mobileMenuOpen = action.payload
}, },
toggleSidebar: state => { toggleSidebar: state => {
state.sidebarVisible = !state.sidebarVisible state.sidebarVisible = !state.sidebarVisible
}, },
incrementUnreadCount: ( incrementUnreadCount: (
state, state,
action: PayloadAction<{ action: PayloadAction<{
feedId: number feedId: number
amount: number amount: number
}> }>
) => { ) => {
if (!state.rootCategory) return if (!state.rootCategory) return
visitCategoryTree(state.rootCategory, c => visitCategoryTree(state.rootCategory, c => {
c.feeds for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) {
.filter(f => f.id === action.payload.feedId) f.unread += action.payload.amount
.forEach(f => { }
f.unread += action.payload.amount })
}) },
) },
}, extraReducers: builder => {
}, builder.addCase(reloadTree.fulfilled, (state, action) => {
extraReducers: builder => { state.rootCategory = action.payload
builder.addCase(reloadTree.fulfilled, (state, action) => { })
state.rootCategory = action.payload builder.addCase(collapseTreeCategory.pending, (state, action) => {
}) if (!state.rootCategory) return
builder.addCase(collapseTreeCategory.pending, (state, action) => { visitCategoryTree(state.rootCategory, c => {
if (!state.rootCategory) return if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
visitCategoryTree(state.rootCategory, c => { })
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse })
}) builder.addCase(markEntry.pending, (state, action) => {
}) if (!state.rootCategory) return
builder.addCase(markEntry.pending, (state, action) => { visitCategoryTree(state.rootCategory, c => {
if (!state.rootCategory) return for (const f of c.feeds.filter(f => f.id === +action.meta.arg.entry.feedId)) {
visitCategoryTree(state.rootCategory, c => f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
c.feeds }
.filter(f => f.id === +action.meta.arg.entry.feedId) })
.forEach(f => { })
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1 builder.addCase(redirectTo, state => {
}) state.mobileMenuOpen = false
) })
}) },
builder.addCase(redirectTo, state => { })
state.mobileMenuOpen = false
}) export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions
},
})
export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions

View File

@@ -1,9 +1,9 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import type { CollapseRequest } from "app/types" import type { CollapseRequest } from "app/types"
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",
async (req: CollapseRequest) => await client.category.collapse(req) async (req: CollapseRequest) => await client.category.collapse(req)
) )

View File

@@ -1,292 +1,306 @@
export type ReadingMode = "all" | "unread" export type ReadingMode = "all" | "unread"
export type ReadingOrder = "asc" | "desc" export type ReadingOrder = "asc" | "desc"
export type ViewMode = "title" | "cozy" | "detailed" | "expanded" export type ViewMode = "title" | "cozy" | "detailed" | "expanded"
export type ScrollMode = "always" | "never" | "if_needed" export type ScrollMode = "always" | "never" | "if_needed"
export interface AddCategoryRequest { export type IconDisplayMode = "always" | "never" | "on_desktop" | "on_mobile"
name: string
parentId?: string export interface AddCategoryRequest {
} name: string
parentId?: string
export interface Subscription { }
id: number
name: string export interface Subscription {
message?: string id: number
errorCount: number name: string
lastRefresh?: number message?: string
nextRefresh?: number errorCount: number
feedUrl: string lastRefresh?: number
feedLink: string nextRefresh?: number
iconUrl: string feedUrl: string
unread: number feedLink: string
categoryId?: string iconUrl: string
position: number unread: number
newestItemTime?: number categoryId?: string
filter?: string position: number
} newestItemTime?: number
filter?: string
export interface Category { }
id: string
parentId?: string export interface Category {
parentName?: string id: string
name: string parentId?: string
children: Category[] parentName?: string
feeds: Subscription[] name: string
expanded: boolean children: Category[]
position: number feeds: Subscription[]
} expanded: boolean
position: number
export interface CategoryModificationRequest { }
id: number
name?: string export interface CategoryModificationRequest {
parentId?: string id: number
position?: number name?: string
} parentId?: string
position?: number
export interface CollapseRequest { }
id: number
collapse: boolean export interface CollapseRequest {
} id: number
collapse: boolean
export interface Entry { }
id: string
guid: string export interface Entry {
title: string id: string
content: string guid: string
categories?: string title: string
rtl: boolean content: string
author?: string categories?: string
enclosureUrl?: string rtl: boolean
enclosureType?: string author?: string
mediaDescription?: string enclosureUrl?: string
mediaThumbnailUrl?: string enclosureType?: string
mediaThumbnailWidth?: number mediaDescription?: string
mediaThumbnailHeight?: number mediaThumbnailUrl?: string
date: number mediaThumbnailWidth?: number
insertedDate: number mediaThumbnailHeight?: number
feedId: string date: number
feedName: string insertedDate: number
feedUrl: string feedId: string
feedLink: string feedName: string
iconUrl: string feedUrl: string
url: string feedLink: string
read: boolean iconUrl: string
starred: boolean url: string
markable: boolean read: boolean
tags: string[] starred: boolean
} markable: boolean
tags: string[]
export interface Entries { }
name: string
message?: string export interface Entries {
errorCount: number name: string
feedLink: string message?: string
timestamp: number errorCount: number
hasMore: boolean feedLink: string
offset?: number timestamp: number
limit?: number hasMore: boolean
entries: Entry[] offset?: number
ignoredReadStatus: boolean limit?: number
} entries: Entry[]
ignoredReadStatus: boolean
export interface FeedInfo { }
url: string
title: string export interface FeedInfo {
} url: string
title: string
export interface FeedInfoRequest { }
url: string
} export interface FeedInfoRequest {
url: string
export interface FeedModificationRequest { }
id: number
name?: string export interface FeedModificationRequest {
categoryId?: string id: number
position?: number name?: string
filter?: string categoryId?: string
} position?: number
filter?: string
export interface GetEntriesRequest { }
id: string
readType?: ReadingMode export interface GetEntriesRequest {
newerThan?: number id: string
order?: ReadingOrder readType?: ReadingMode
keywords?: string newerThan?: number
onlyIds?: boolean order?: ReadingOrder
excludedSubscriptionIds?: string keywords?: string
tag?: string excludedSubscriptionIds?: string
} tag?: string
}
export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
offset: number export interface GetEntriesPaginatedRequest extends GetEntriesRequest {
limit: number offset: number
} limit: number
}
export interface IDRequest {
id: number export interface IDRequest {
} id: number
}
export interface LoginRequest {
name: string export interface LoginRequest {
password: string name: string
} password: string
}
export interface MarkRequest {
id: string export interface MarkRequest {
read: boolean id: string
olderThan?: number read: boolean
insertedBefore?: number olderThan?: number
keywords?: string insertedBefore?: number
excludedSubscriptions?: number[] keywords?: string
} excludedSubscriptions?: number[]
}
export interface MetricCounter {
count: number export interface MetricCounter {
} count: number
}
export interface MetricGauge {
value: number export interface MetricGauge {
} value: number
}
export interface MetricMeter {
count: number export interface MetricMeter {
m15_rate: number count: number
m1_rate: number m15_rate: number
m5_rate: number m1_rate: number
mean_rate: number m5_rate: number
units: string mean_rate: number
} units: string
}
export interface MetricTimer {
count: number export interface MetricTimer {
max: number count: number
mean: number max: number
min: number mean: number
p50: number min: number
p75: number p50: number
p95: number p75: number
p98: number p95: number
p99: number p98: number
p999: number p99: number
stddev: number p999: number
m15_rate: number stddev: number
m1_rate: number m15_rate: number
m5_rate: number m1_rate: number
mean_rate: number m5_rate: number
duration_units: string mean_rate: number
rate_units: string duration_units: string
} rate_units: string
}
export interface Metrics {
counters: Record<string, MetricCounter> export interface Metrics {
gauges: Record<string, MetricGauge> counters: Record<string, MetricCounter>
meters: Record<string, MetricMeter> gauges: Record<string, MetricGauge>
timers: Record<string, MetricTimer> meters: Record<string, MetricMeter>
} timers: Record<string, MetricTimer>
}
export interface MultipleMarkRequest {
requests: MarkRequest[] export interface MultipleMarkRequest {
} requests: MarkRequest[]
}
export interface PasswordResetRequest {
email: string export interface PasswordResetRequest {
} email: string
}
export interface ProfileModificationRequest {
currentPassword: string export interface ProfileModificationRequest {
email: string currentPassword: string
newPassword?: string email: string
newApiKey?: boolean newPassword?: string
} newApiKey?: boolean
}
export interface RegistrationRequest {
name: string export interface RegistrationRequest {
password: string name: string
email: string password: string
} email: string
}
export interface ServerInfo {
announcement?: string export interface ServerInfo {
version: string announcement?: string
gitCommit: string version: string
allowRegistrations: boolean gitCommit: string
googleAnalyticsCode?: string allowRegistrations: boolean
smtpEnabled: boolean googleAnalyticsCode?: string
demoAccountEnabled: boolean smtpEnabled: boolean
websocketEnabled: boolean demoAccountEnabled: boolean
websocketPingInterval: number websocketEnabled: boolean
treeReloadInterval: number websocketPingInterval: number
} treeReloadInterval: number
forceRefreshCooldownDuration: number
export interface SharingSettings { }
email: boolean
gmail: boolean export interface SharingSettings {
facebook: boolean email: boolean
twitter: boolean gmail: boolean
tumblr: boolean facebook: boolean
pocket: boolean twitter: boolean
instapaper: boolean tumblr: boolean
buffer: boolean pocket: boolean
} instapaper: boolean
buffer: boolean
export interface Settings { }
language: string
readingMode: ReadingMode export interface Settings {
readingOrder: ReadingOrder language: string
showRead: boolean readingMode: ReadingMode
scrollMarks: boolean readingOrder: ReadingOrder
customCss?: string showRead: boolean
customJs?: string scrollMarks: boolean
scrollSpeed: number customCss?: string
scrollMode: ScrollMode customJs?: string
markAllAsReadConfirmation: boolean scrollSpeed: number
customContextMenu: boolean scrollMode: ScrollMode
mobileFooter: boolean entriesToKeepOnTopWhenScrolling: number
sharingSettings: SharingSettings starIconDisplayMode: IconDisplayMode
} externalLinkIconDisplayMode: IconDisplayMode
markAllAsReadConfirmation: boolean
export interface StarRequest { customContextMenu: boolean
id: string mobileFooter: boolean
feedId: number unreadCountTitle: boolean
starred: boolean unreadCountFavicon: boolean
} sharingSettings: SharingSettings
}
export interface SubscribeRequest {
url: string export interface LocalSettings {
title: string viewMode: ViewMode
categoryId?: string sidebarWidth: number
} announcementHash: string
}
export interface TagRequest {
entryId: number export interface StarRequest {
tags: string[] id: string
} feedId: number
starred: boolean
export interface UserModel { }
id: number
name: string export interface SubscribeRequest {
email?: string url: string
apiKey?: string title: string
password?: string categoryId?: string
enabled: boolean }
created: number
lastLogin?: number export interface TagRequest {
admin: boolean entryId: number
} tags: string[]
}
export interface AdminSaveUserRequest {
id?: number export interface UserModel {
name: string id: number
email?: string name: string
password?: string email?: string
enabled: boolean apiKey?: string
admin: boolean password?: string
} enabled: boolean
created: number
export interface AuthenticationError { lastLogin?: number
message: string admin: boolean
allowRegistrations: boolean lastForceRefresh?: number
} }
export interface AdminSaveUserRequest {
id?: number
name: string
email?: string
password?: string
enabled: boolean
admin: boolean
}
export interface AuthenticationError {
message: string
allowRegistrations: boolean
}

View File

@@ -1,108 +1,159 @@
import { t } from "@lingui/macro" import { t } from "@lingui/macro"
import { showNotification } from "@mantine/notifications" import { showNotification } from "@mantine/notifications"
import { createSlice, isAnyOf } from "@reduxjs/toolkit" import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit"
import { type Settings, type UserModel } from "app/types" import type { LocalSettings, Settings, UserModel, ViewMode } from "app/types"
import { import {
changeCustomContextMenu, changeCustomContextMenu,
changeLanguage, changeEntriesToKeepOnTopWhenScrolling,
changeMarkAllAsReadConfirmation, changeExternalLinkIconDisplayMode,
changeMobileFooter, changeLanguage,
changeReadingMode, changeMarkAllAsReadConfirmation,
changeReadingOrder, changeMobileFooter,
changeScrollMarks, changeReadingMode,
changeScrollMode, changeReadingOrder,
changeScrollSpeed, changeScrollMarks,
changeSharingSetting, changeScrollMode,
changeShowRead, changeScrollSpeed,
reloadProfile, changeSharingSetting,
reloadSettings, changeShowRead,
reloadTags, changeStarIconDisplayMode,
} from "./thunks" changeUnreadCountFavicon,
changeUnreadCountTitle,
interface UserState { reloadProfile,
settings?: Settings reloadSettings,
profile?: UserModel reloadTags,
tags?: string[] } from "./thunks"
}
interface UserState {
const initialState: UserState = {} settings?: Settings
localSettings: LocalSettings
export const userSlice = createSlice({ profile?: UserModel
name: "user", tags?: string[]
initialState, }
reducers: {},
extraReducers: builder => { export const initialLocalSettings: LocalSettings = {
builder.addCase(reloadSettings.fulfilled, (state, action) => { viewMode: "detailed",
state.settings = action.payload sidebarWidth: 360,
}) announcementHash: "no-hash",
builder.addCase(reloadProfile.fulfilled, (state, action) => { }
state.profile = action.payload
}) const initialState: UserState = {
builder.addCase(reloadTags.fulfilled, (state, action) => { localSettings: initialLocalSettings,
state.tags = action.payload }
})
builder.addCase(changeReadingMode.pending, (state, action) => { export const userSlice = createSlice({
if (!state.settings) return name: "user",
state.settings.readingMode = action.meta.arg initialState,
}) reducers: {
builder.addCase(changeReadingOrder.pending, (state, action) => { setViewMode: (state, action: PayloadAction<ViewMode>) => {
if (!state.settings) return state.localSettings.viewMode = action.payload
state.settings.readingOrder = action.meta.arg },
}) setSidebarWidth: (state, action: PayloadAction<number>) => {
builder.addCase(changeLanguage.pending, (state, action) => { state.localSettings.sidebarWidth = action.payload
if (!state.settings) return },
state.settings.language = action.meta.arg setAnnouncementHash: (state, action: PayloadAction<string>) => {
}) state.localSettings.announcementHash = action.payload
builder.addCase(changeScrollSpeed.pending, (state, action) => { },
if (!state.settings) return },
state.settings.scrollSpeed = action.meta.arg ? 400 : 0 extraReducers: builder => {
}) builder.addCase(reloadSettings.fulfilled, (state, action) => {
builder.addCase(changeShowRead.pending, (state, action) => { state.settings = action.payload
if (!state.settings) return })
state.settings.showRead = action.meta.arg builder.addCase(reloadProfile.fulfilled, (state, action) => {
}) state.profile = action.payload
builder.addCase(changeScrollMarks.pending, (state, action) => { })
if (!state.settings) return builder.addCase(reloadTags.fulfilled, (state, action) => {
state.settings.scrollMarks = action.meta.arg state.tags = action.payload
}) })
builder.addCase(changeScrollMode.pending, (state, action) => { builder.addCase(changeReadingMode.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.scrollMode = action.meta.arg state.settings.readingMode = action.meta.arg
}) })
builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => { builder.addCase(changeReadingOrder.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.markAllAsReadConfirmation = action.meta.arg state.settings.readingOrder = action.meta.arg
}) })
builder.addCase(changeCustomContextMenu.pending, (state, action) => { builder.addCase(changeLanguage.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.customContextMenu = action.meta.arg state.settings.language = action.meta.arg
}) })
builder.addCase(changeMobileFooter.pending, (state, action) => { builder.addCase(changeScrollSpeed.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.mobileFooter = action.meta.arg state.settings.scrollSpeed = action.meta.arg ? 400 : 0
}) })
builder.addCase(changeSharingSetting.pending, (state, action) => { builder.addCase(changeShowRead.pending, (state, action) => {
if (!state.settings) return if (!state.settings) return
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value state.settings.showRead = action.meta.arg
}) })
builder.addMatcher( builder.addCase(changeScrollMarks.pending, (state, action) => {
isAnyOf( if (!state.settings) return
changeLanguage.fulfilled, state.settings.scrollMarks = action.meta.arg
changeScrollSpeed.fulfilled, })
changeShowRead.fulfilled, builder.addCase(changeScrollMode.pending, (state, action) => {
changeScrollMarks.fulfilled, if (!state.settings) return
changeScrollMode.fulfilled, state.settings.scrollMode = action.meta.arg
changeMarkAllAsReadConfirmation.fulfilled, })
changeCustomContextMenu.fulfilled, builder.addCase(changeEntriesToKeepOnTopWhenScrolling.pending, (state, action) => {
changeMobileFooter.fulfilled, if (!state.settings) return
changeSharingSetting.fulfilled state.settings.entriesToKeepOnTopWhenScrolling = action.meta.arg
), })
() => { builder.addCase(changeStarIconDisplayMode.pending, (state, action) => {
showNotification({ if (!state.settings) return
message: t`Settings saved.`, state.settings.starIconDisplayMode = action.meta.arg
color: "green", })
}) builder.addCase(changeExternalLinkIconDisplayMode.pending, (state, action) => {
} if (!state.settings) return
) state.settings.externalLinkIconDisplayMode = action.meta.arg
}, })
}) builder.addCase(changeMarkAllAsReadConfirmation.pending, (state, action) => {
if (!state.settings) return
state.settings.markAllAsReadConfirmation = action.meta.arg
})
builder.addCase(changeCustomContextMenu.pending, (state, action) => {
if (!state.settings) return
state.settings.customContextMenu = action.meta.arg
})
builder.addCase(changeMobileFooter.pending, (state, action) => {
if (!state.settings) return
state.settings.mobileFooter = action.meta.arg
})
builder.addCase(changeUnreadCountTitle.pending, (state, action) => {
if (!state.settings) return
state.settings.unreadCountTitle = action.meta.arg
})
builder.addCase(changeUnreadCountFavicon.pending, (state, action) => {
if (!state.settings) return
state.settings.unreadCountFavicon = action.meta.arg
})
builder.addCase(changeSharingSetting.pending, (state, action) => {
if (!state.settings) return
state.settings.sharingSettings[action.meta.arg.site] = action.meta.arg.value
})
builder.addMatcher(
isAnyOf(
changeLanguage.fulfilled,
changeScrollSpeed.fulfilled,
changeShowRead.fulfilled,
changeScrollMarks.fulfilled,
changeScrollMode.fulfilled,
changeEntriesToKeepOnTopWhenScrolling.fulfilled,
changeStarIconDisplayMode.fulfilled,
changeExternalLinkIconDisplayMode.fulfilled,
changeMarkAllAsReadConfirmation.fulfilled,
changeCustomContextMenu.fulfilled,
changeMobileFooter.fulfilled,
changeUnreadCountTitle.fulfilled,
changeUnreadCountFavicon.fulfilled,
changeSharingSetting.fulfilled
),
() => {
showNotification({
message: t`Settings saved.`,
color: "green",
})
}
)
},
})
export const { setViewMode, setSidebarWidth, setAnnouncementHash } = userSlice.actions

View File

@@ -1,83 +1,117 @@
import { createAppAsyncThunk } from "app/async-thunk" import { createAppAsyncThunk } from "app/async-thunk"
import { client } from "app/client" import { client } from "app/client"
import { reloadEntries } from "app/entries/thunks" import { reloadEntries } from "app/entries/thunks"
import type { ReadingMode, ReadingOrder, ScrollMode, SharingSettings } from "app/types" import type { IconDisplayMode, ReadingMode, ReadingOrder, ScrollMode, SharingSettings } 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 changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => { export const changeReadingMode = createAppAsyncThunk("settings/readingMode", (readingMode: ReadingMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, readingMode }) client.user.saveSettings({ ...settings, readingMode })
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
}) })
export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => { export const changeReadingOrder = createAppAsyncThunk("settings/readingOrder", (readingOrder: ReadingOrder, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, readingOrder }) client.user.saveSettings({ ...settings, readingOrder })
thunkApi.dispatch(reloadEntries()) thunkApi.dispatch(reloadEntries())
}) })
export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => { export const changeLanguage = createAppAsyncThunk("settings/language", (language: string, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, language }) client.user.saveSettings({ ...settings, language })
}) })
export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => { export const changeScrollSpeed = createAppAsyncThunk("settings/scrollSpeed", (speed: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 }) client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
}) })
export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => { export const changeShowRead = createAppAsyncThunk("settings/showRead", (showRead: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, showRead }) client.user.saveSettings({ ...settings, showRead })
}) })
export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => { export const changeScrollMarks = createAppAsyncThunk("settings/scrollMarks", (scrollMarks: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollMarks }) client.user.saveSettings({ ...settings, scrollMarks })
}) })
export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => { export const changeScrollMode = createAppAsyncThunk("settings/scrollMode", (scrollMode: ScrollMode, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, scrollMode }) client.user.saveSettings({ ...settings, scrollMode })
}) })
export const changeMarkAllAsReadConfirmation = createAppAsyncThunk( export const changeEntriesToKeepOnTopWhenScrolling = createAppAsyncThunk(
"settings/markAllAsReadConfirmation", "settings/entriesToKeepOnTopWhenScrolling",
(markAllAsReadConfirmation: boolean, thunkApi) => { (entriesToKeepOnTopWhenScrolling: number, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ ...settings, markAllAsReadConfirmation }) client.user.saveSettings({ ...settings, entriesToKeepOnTopWhenScrolling })
} }
) )
export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => { export const changeStarIconDisplayMode = createAppAsyncThunk(
const { settings } = thunkApi.getState().user "settings/starIconDisplayMode",
if (!settings) return (starIconDisplayMode: IconDisplayMode, thunkApi) => {
client.user.saveSettings({ ...settings, customContextMenu }) const { settings } = thunkApi.getState().user
}) if (!settings) return
export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => { client.user.saveSettings({ ...settings, starIconDisplayMode })
const { settings } = thunkApi.getState().user }
if (!settings) return )
client.user.saveSettings({ ...settings, mobileFooter }) export const changeExternalLinkIconDisplayMode = createAppAsyncThunk(
}) "settings/externalLinkIconDisplayMode",
export const changeSharingSetting = createAppAsyncThunk( (externalLinkIconDisplayMode: IconDisplayMode, thunkApi) => {
"settings/sharingSetting", const { settings } = thunkApi.getState().user
( if (!settings) return
sharingSetting: { client.user.saveSettings({ ...settings, externalLinkIconDisplayMode })
site: keyof SharingSettings }
value: boolean )
}, export const changeMarkAllAsReadConfirmation = createAppAsyncThunk(
thunkApi "settings/markAllAsReadConfirmation",
) => { (markAllAsReadConfirmation: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user const { settings } = thunkApi.getState().user
if (!settings) return if (!settings) return
client.user.saveSettings({ client.user.saveSettings({ ...settings, markAllAsReadConfirmation })
...settings, }
sharingSettings: { )
...settings.sharingSettings, export const changeCustomContextMenu = createAppAsyncThunk("settings/customContextMenu", (customContextMenu: boolean, thunkApi) => {
[sharingSetting.site]: sharingSetting.value, const { settings } = thunkApi.getState().user
}, if (!settings) return
}) client.user.saveSettings({ ...settings, customContextMenu })
} })
) export const changeMobileFooter = createAppAsyncThunk("settings/mobileFooter", (mobileFooter: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, mobileFooter })
})
export const changeUnreadCountTitle = createAppAsyncThunk("settings/unreadCountTitle", (unreadCountTitle: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, unreadCountTitle })
})
export const changeUnreadCountFavicon = createAppAsyncThunk("settings/unreadCountFavicon", (unreadCountFavicon: boolean, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, unreadCountFavicon })
})
export const changeSharingSetting = createAppAsyncThunk(
"settings/sharingSetting",
(
sharingSetting: {
site: keyof SharingSettings
value: boolean
},
thunkApi
) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({
...settings,
sharingSettings: {
...settings.sharingSettings,
[sharingSetting.site]: sharingSetting.value,
},
})
}
)

View File

@@ -1,47 +1,49 @@
import { throttle } from "throttle-debounce" import { throttle } from "throttle-debounce"
import { type Category } from "./types" import type { Category } from "./types"
export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void { export function visitCategoryTree(category: Category, visitor: (category: Category) => void): void {
visitor(category) visitor(category)
category.children.forEach(child => visitCategoryTree(child, visitor)) for (const child of category.children) {
} visitCategoryTree(child, visitor)
}
export function flattenCategoryTree(category: Category): Category[] { }
const categories: Category[] = []
visitCategoryTree(category, c => categories.push(c)) export function flattenCategoryTree(category: Category): Category[] {
return categories const categories: Category[] = []
} visitCategoryTree(category, c => categories.push(c))
return categories
export function categoryUnreadCount(category?: Category): number { }
if (!category) return 0
export function categoryUnreadCount(category?: Category): number {
return flattenCategoryTree(category) if (!category) return 0
.flatMap(c => c.feeds)
.map(f => f.unread) return flattenCategoryTree(category)
.reduce((total, current) => total + current, 0) .flatMap(c => c.feeds)
} .map(f => f.unread)
.reduce((total, current) => total + current, 0)
export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => { }
const placeholderWidth = width && Math.min(width, maxWidth)
const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
return { width: placeholderWidth, height: placeholderHeight } const placeholderWidth = width && Math.min(width, maxWidth)
} const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
return { width: placeholderWidth, height: placeholderHeight }
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => { }
const offset = (options.top ?? 0).toFixed()
export const scrollToWithCallback = ({ options, onScrollEnded }: { options: ScrollToOptions; onScrollEnded: () => void }) => {
const onScroll = throttle(100, () => { const offset = (options.top ?? 0).toFixed()
if (window.scrollY.toFixed() === offset) {
window.removeEventListener("scroll", onScroll) const onScroll = throttle(100, () => {
onScrollEnded() if (window.scrollY.toFixed() === offset) {
} window.removeEventListener("scroll", onScroll)
}) onScrollEnded()
window.addEventListener("scroll", onScroll) }
})
// scrollTo does not trigger if there's nothing to do, trigger it manually window.addEventListener("scroll", onScroll)
onScroll()
// scrollTo does not trigger if there's nothing to do, trigger it manually
window.scrollTo(options) onScroll()
}
window.scrollTo(options)
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str) }
export const truncate = (str: string, n: number) => (str.length > n ? `${str.slice(0, n - 1)}\u2026` : str)

View File

@@ -1,36 +1,57 @@
import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core" import type { MessageDescriptor } from "@lingui/core"
import { type ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon" import { useLingui } from "@lingui/react"
import { useActionButton } from "hooks/useActionButton" import { ActionIcon, Button, type ButtonVariant, Tooltip, useMantineTheme } from "@mantine/core"
import { forwardRef, type MouseEventHandler, type ReactNode } from "react" import type { ActionIconVariant } from "@mantine/core/lib/components/ActionIcon/ActionIcon"
import { Constants } from "app/constants"
interface ActionButtonProps { import { useActionButton } from "hooks/useActionButton"
className?: string import { type MouseEventHandler, type ReactNode, forwardRef } from "react"
icon?: ReactNode
label: ReactNode interface ActionButtonProps {
onClick?: MouseEventHandler className?: string
variant?: ActionIconVariant & ButtonVariant icon?: ReactNode
hideLabelOnDesktop?: boolean label?: string | MessageDescriptor
showLabelOnMobile?: boolean onClick?: MouseEventHandler
} variant?: ActionIconVariant & ButtonVariant
hideLabelOnDesktop?: boolean
/** showLabelOnMobile?: boolean
* Switches between Button with label (desktop) and ActionIcon (mobile) }
*/
export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => { /**
const { mobile } = useActionButton() * Switches between Button with label (desktop) and ActionIcon (mobile)
const theme = useMantineTheme() */
const variant = props.variant ?? "subtle" export const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>((props: ActionButtonProps, ref) => {
const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop) const { mobile } = useActionButton()
return iconOnly ? ( const theme = useMantineTheme()
<Tooltip label={props.label} openDelay={500}> const { _ } = useLingui()
<ActionIcon ref={ref} color={theme.primaryColor} variant={variant} className={props.className} onClick={props.onClick}>
{props.icon} const label = typeof props.label === "string" ? props.label : props.label && _(props.label)
</ActionIcon> const variant = props.variant ?? "subtle"
</Tooltip> const iconOnly = (mobile && !props.showLabelOnMobile) || (!mobile && props.hideLabelOnDesktop)
) : ( return iconOnly ? (
<Button ref={ref} variant={variant} size="xs" className={props.className} leftSection={props.icon} onClick={props.onClick}> <Tooltip label={label} openDelay={Constants.tooltip.delay}>
{props.label} <ActionIcon
</Button> ref={ref}
) color={theme.primaryColor}
}) variant={variant}
ActionButton.displayName = "HeaderButton" className={props.className}
onClick={props.onClick}
aria-label={label}
>
{props.icon}
</ActionIcon>
</Tooltip>
) : (
<Button
ref={ref}
variant={variant}
size="xs"
className={props.className}
leftSection={props.icon}
onClick={props.onClick}
aria-label={label}
>
{label}
</Button>
)
})
ActionButton.displayName = "HeaderButton"

View File

@@ -1,47 +1,47 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Alert as MantineAlert, Box } from "@mantine/core" import { Box, Alert as MantineAlert } from "@mantine/core"
import { Fragment } from "react" import { Fragment } from "react"
import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb" import { TbAlertCircle, TbAlertTriangle, TbCircleCheck } from "react-icons/tb"
type Level = "error" | "warning" | "success" type Level = "error" | "warning" | "success"
export interface ErrorsAlertProps { export interface ErrorsAlertProps {
level?: Level level?: Level
messages: string[] messages: string[]
} }
export function Alert(props: ErrorsAlertProps) { export function Alert(props: ErrorsAlertProps) {
let title: React.ReactNode let title: React.ReactNode
let color: string let color: string
let icon: React.ReactNode let icon: React.ReactNode
const level = props.level ?? "error" const level = props.level ?? "error"
switch (level) { switch (level) {
case "error": case "error":
title = <Trans>Error</Trans> title = <Trans>Error</Trans>
color = "red" color = "red"
icon = <TbAlertCircle /> icon = <TbAlertCircle />
break break
case "warning": case "warning":
title = <Trans>Warning</Trans> title = <Trans>Warning</Trans>
color = "orange" color = "orange"
icon = <TbAlertTriangle /> icon = <TbAlertTriangle />
break break
case "success": case "success":
title = <Trans>Success</Trans> title = <Trans>Success</Trans>
color = "green" color = "green"
icon = <TbCircleCheck /> icon = <TbCircleCheck />
break break
} }
return ( return (
<MantineAlert title={title} color={color} icon={icon}> <MantineAlert title={title} color={color} icon={icon}>
{props.messages.map((m, i) => ( {props.messages.map((m, i) => (
<Fragment key={m}> <Fragment key={m}>
<Box>{m}</Box> <Box>{m}</Box>
{i !== props.messages.length - 1 && <br />} {i !== props.messages.length - 1 && <br />}
</Fragment> </Fragment>
))} ))}
</MantineAlert> </MantineAlert>
) )
} }

View File

@@ -1,9 +1,9 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Dialog, Text } from "@mantine/core" import { Box, Dialog, Text } from "@mantine/core"
import { useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { setAnnouncementHash } from "app/user/slice"
import { Content } from "components/content/Content" import { Content } from "components/content/Content"
import { useAsync } from "react-async-hook" import { useAsync } from "react-async-hook"
import useLocalStorage from "use-local-storage"
const sha256Hex = async (input: string | undefined) => { const sha256Hex = async (input: string | undefined) => {
const data = new TextEncoder().encode(input) const data = new TextEncoder().encode(input)
@@ -15,10 +15,11 @@ const sha256Hex = async (input: string | undefined) => {
export function AnnouncementDialog() { export function AnnouncementDialog() {
const announcement = useAppSelector(state => state.server.serverInfos?.announcement) const announcement = useAppSelector(state => state.server.serverInfos?.announcement)
const announcementHash = useAsync(sha256Hex, [announcement]).result const announcementHash = useAsync(sha256Hex, [announcement]).result
const [localStorageHash, setLocalStorageHash] = useLocalStorage("announcement-hash", "no-hash") const existingAnnouncementHash = useAppSelector(state => state.user.localSettings.announcementHash)
const dispatch = useAppDispatch()
const opened = !!announcementHash && announcementHash !== localStorageHash const opened = !!announcementHash && announcementHash !== existingAnnouncementHash
const onClosed = () => setLocalStorageHash(announcementHash) const onClosed = () => announcementHash && dispatch(setAnnouncementHash(announcementHash))
if (!announcement) return null if (!announcement) return null
return ( return (

View File

@@ -0,0 +1,15 @@
import { Helmet } from "react-helmet"
export const DisablePullToRefresh = () => {
return (
<Helmet>
<style type="text/css">
{`
html, body {
overscroll-behavior: none;
}
`}
</style>
</Helmet>
)
}

View File

@@ -1,26 +1,26 @@
import { ErrorPage } from "pages/ErrorPage" import { ErrorPage } from "pages/ErrorPage"
import React, { type ReactNode } from "react" import React, { type ReactNode } from "react"
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children?: ReactNode children?: ReactNode
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
error?: Error error?: Error
} }
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) { constructor(props: ErrorBoundaryProps) {
super(props) super(props)
this.state = {} this.state = {}
} }
componentDidCatch(error: Error) { componentDidCatch(error: Error) {
this.setState({ error }) this.setState({ error })
} }
render() { render() {
if (this.state.error) return <ErrorPage error={this.state.error} /> if (this.state.error) return <ErrorPage error={this.state.error} />
return this.props.children return this.props.children
} }
} }

View File

@@ -1,75 +1,75 @@
import { Box, Center } from "@mantine/core" import { Box, Center } from "@mantine/core"
import { useState } from "react" import { useState } from "react"
import { TbPhoto } from "react-icons/tb" import { TbPhoto } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
interface ImageWithPlaceholderWhileLoadingProps { interface ImageWithPlaceholderWhileLoadingProps {
src: string src: string
alt: string alt: string
title?: string title?: string
width?: number width?: number
height?: number | "auto" height?: number | "auto"
placeholderWidth?: number placeholderWidth?: number
placeholderHeight?: number placeholderHeight?: number
placeholderBackgroundColor?: string placeholderBackgroundColor?: string
placeholderIconSize?: number placeholderIconSize?: number
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
placeholderWidth?: number placeholderWidth?: number
placeholderHeight?: number placeholderHeight?: number
placeholderBackgroundColor?: string placeholderBackgroundColor?: string
}>() }>()
.create(props => ({ .create(props => ({
placeholder: { placeholder: {
width: props.placeholderWidth ?? 400, width: props.placeholderWidth ?? 400,
height: props.placeholderHeight ?? 600, height: props.placeholderHeight ?? 600,
maxWidth: "100%", maxWidth: "100%",
backgroundColor: backgroundColor:
props.placeholderBackgroundColor ?? props.placeholderBackgroundColor ??
(props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]), (props.colorScheme === "dark" ? props.theme.colors.dark[5] : props.theme.colors.gray[1]),
}, },
})) }))
export function ImageWithPlaceholderWhileLoading({ export function ImageWithPlaceholderWhileLoading({
alt, alt,
height, height,
placeholderBackgroundColor, placeholderBackgroundColor,
placeholderHeight, placeholderHeight,
placeholderIconSize, placeholderIconSize,
placeholderWidth, placeholderWidth,
src, src,
title, title,
width, width,
}: ImageWithPlaceholderWhileLoadingProps) { }: ImageWithPlaceholderWhileLoadingProps) {
const { classes } = useStyles({ const { classes } = useStyles({
placeholderWidth, placeholderWidth,
placeholderHeight, placeholderHeight,
placeholderBackgroundColor, placeholderBackgroundColor,
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
return ( return (
<> <>
{loading && ( {loading && (
<Box> <Box>
<Center className={classes.placeholder}> <Center className={classes.placeholder}>
<div> <div>
<TbPhoto size={placeholderIconSize ?? 48} /> <TbPhoto size={placeholderIconSize ?? 48} />
</div> </div>
</Center> </Center>
</Box> </Box>
)} )}
<img <img
src={src} src={src}
alt={alt} alt={alt}
title={title} title={title}
width={width} width={width}
height={height} height={height}
onLoad={() => setLoading(false)} onLoad={() => setLoading(false)}
style={{ display: loading ? "none" : "block" }} style={{ display: loading ? "none" : "block" }}
/> />
</> </>
) )
} }

View File

@@ -1,222 +1,222 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core" import { Anchor, Box, Kbd, Stack, Table } from "@mantine/core"
import { Constants } from "app/constants" import { useOs } from "@mantine/hooks"
import { Constants } from "app/constants"
export function KeyboardShortcutsHelp() {
return ( export function KeyboardShortcutsHelp() {
<Stack gap="xs"> const isMacOS = useOs() === "macos"
<Table striped highlightOnHover> return (
<Table.Tbody> <Stack gap="xs">
<Table.Tr> <Table striped highlightOnHover>
<Table.Td> <Table.Tbody>
<Trans>Refresh</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Refresh</Trans>
<Kbd>R</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>R</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Open next entry</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Open next entry</Trans>
<Kbd>J</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>J</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Open previous entry</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Open previous entry</Trans>
<Kbd>K</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>K</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Set focus on next entry without opening it</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Set focus on next entry without opening it</Trans>
<Kbd>N</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>N</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Set focus on previous entry without opening it</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Set focus on previous entry without opening it</Trans>
<Kbd>P</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>P</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Move the page down</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Move the page down</Trans>
<Kbd> </Table.Td>
<Trans>Space</Trans> <Table.Td>
</Kbd> <Kbd>
</Table.Td> <Trans>Space</Trans>
</Table.Tr> </Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Move the page up</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Move the page up</Trans>
<Kbd> </Table.Td>
<Trans>Shift</Trans> <Table.Td>
</Kbd> <Kbd>
<span> + </span> <Trans>Shift</Trans>
<Kbd> </Kbd>
<Trans>Space</Trans> <span> + </span>
</Kbd> <Kbd>
</Table.Td> <Trans>Space</Trans>
</Table.Tr> </Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Open/close current entry</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Open/close current entry</Trans>
<Kbd>O</Kbd> </Table.Td>
<span>, </span> <Table.Td>
<Kbd> <Kbd>O</Kbd>
<Trans>Enter</Trans> <span>, </span>
</Kbd> <Kbd>
</Table.Td> <Trans>Enter</Trans>
</Table.Tr> </Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Open current entry in a new tab</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Open current entry in a new tab</Trans>
<Kbd>V</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>V</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Open current entry in a new tab in the background</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Open current entry in a new tab in the background</Trans>
<Kbd>B</Kbd> </Table.Td>
<span>*, </span> <Table.Td>
<Kbd> <Kbd>B</Kbd>
<Trans>Middle click</Trans> <span>*, </span>
</Kbd> <Kbd>
</Table.Td> <Trans>Middle click</Trans>
</Table.Tr> </Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Toggle read status of current entry</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Toggle read status of current entry</Trans>
<Kbd>M</Kbd> </Table.Td>
<span>, </span> <Table.Td>
<Trans>Swipe header to the left</Trans> <Kbd>M</Kbd>
</Table.Td> <span>, </span>
</Table.Tr> <Trans>Swipe header to the left</Trans>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Toggle starred status of current entry</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Toggle starred status of current entry</Trans>
<Kbd>S</Kbd> </Table.Td>
</Table.Td> <Table.Td>
</Table.Tr> <Kbd>S</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Mark all entries as read</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Mark all entries as read</Trans>
<Kbd> </Table.Td>
<Trans>Shift</Trans> <Table.Td>
</Kbd> <Kbd>
<span> + </span> <Trans>Shift</Trans>
<Kbd>A</Kbd> </Kbd>
</Table.Td> <span> + </span>
</Table.Tr> <Kbd>A</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Go to the All view</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Go to the All view</Trans>
<Kbd>G</Kbd> </Table.Td>
<span> </span> <Table.Td>
<Kbd>A</Kbd> <Kbd>G</Kbd>
</Table.Td> <span> </span>
</Table.Tr> <Kbd>A</Kbd>
<Table.Tr> </Table.Td>
<Table.Td> </Table.Tr>
<Trans>Navigate to a subscription by entering its name</Trans> <Table.Tr>
</Table.Td> <Table.Td>
<Table.Td> <Trans>Navigate to a subscription by entering its name</Trans>
<Kbd> </Table.Td>
<Trans>Ctrl</Trans> <Table.Td>
</Kbd> <Kbd>{isMacOS ? <Trans>Cmd</Trans> : <Trans>Ctrl</Trans>}</Kbd>
<span> + </span> <span> + </span>
<Kbd>K</Kbd> <Kbd>K</Kbd>
<span>, </span> <span>, </span>
<Kbd>G</Kbd> <Kbd>G</Kbd>
<span> </span> <span> </span>
<Kbd>U</Kbd> <Kbd>U</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show entry menu (desktop)</Trans> <Trans>Show entry menu (desktop)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Right click</Trans> <Trans>Right click</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show native menu (desktop)</Trans> <Trans>Show native menu (desktop)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Shift</Trans> <Trans>Shift</Trans>
</Kbd> </Kbd>
<span> + </span> <span> + </span>
<Kbd> <Kbd>
<Trans>Right click</Trans> <Trans>Right click</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show entry menu (mobile)</Trans> <Trans>Show entry menu (mobile)</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd> <Kbd>
<Trans>Long press</Trans> <Trans>Long press</Trans>
</Kbd> </Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Toggle sidebar</Trans> <Trans>Toggle sidebar</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>F</Kbd> <Kbd>F</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
<Table.Tr> <Table.Tr>
<Table.Td> <Table.Td>
<Trans>Show keyboard shortcut help</Trans> <Trans>Show keyboard shortcut help</Trans>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Kbd>?</Kbd> <Kbd>?</Kbd>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<Box> <Box>
<span>* </span> <span>* </span>
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer"> <Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
<Trans>Browser extension required for Chrome</Trans> <Trans>Browser extension required for Chrome</Trans>
</Anchor> </Anchor>
</Box> </Box>
</Stack> </Stack>
) )
} }

View File

@@ -1,9 +1,9 @@
import { Center, Loader as MantineLoader } from "@mantine/core" import { Center, Loader as MantineLoader } from "@mantine/core"
export function Loader() { export function Loader() {
return ( return (
<Center> <Center>
<MantineLoader size="lg" type="bars" /> <MantineLoader size="lg" type="bars" />
</Center> </Center>
) )
} }

View File

@@ -1,10 +1,10 @@
import { Image } from "@mantine/core" import { Image } from "@mantine/core"
import logo from "assets/logo.svg" import logo from "assets/logo.svg"
export interface LogoProps { export interface LogoProps {
size: number size: number
} }
export function Logo(props: LogoProps) { export function Logo(props: LogoProps) {
return <Image src={logo} w={props.size} /> return <Image src={logo} w={props.size} />
} }

View File

@@ -1,20 +1,17 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Tooltip } from "@mantine/core" import { Tooltip } from "@mantine/core"
import dayjs from "dayjs" import { Constants } from "app/constants"
import { useEffect, useState } from "react" import dayjs from "dayjs"
import { useNow } from "hooks/useNow"
export function RelativeDate(props: { date: Date | number | undefined }) {
const [now, setNow] = useState(new Date()) export function RelativeDate(props: { date: Date | number | undefined }) {
useEffect(() => { const now = useNow(60 * 1000)
const interval = setInterval(() => setNow(new Date()), 60 * 1000)
return () => clearInterval(interval) if (!props.date) return <Trans>N/A</Trans>
}, []) const date = dayjs(props.date)
return (
if (!props.date) return <Trans>N/A</Trans> <Tooltip label={date.toDate().toLocaleString()} openDelay={Constants.tooltip.delay}>
const date = dayjs(props.date) <span>{date.from(dayjs(now))}</span>
return ( </Tooltip>
<Tooltip label={date.toDate().toLocaleString()} openDelay={500}> )
<span>{date.from(dayjs(now))}</span> }
</Tooltip>
)
}

View File

@@ -1,54 +1,54 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core" import { Box, Button, Checkbox, Group, PasswordInput, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { type AdminSaveUserRequest, type UserModel } from "app/types" import type { AdminSaveUserRequest, UserModel } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy } from "react-icons/tb" import { TbDeviceFloppy } from "react-icons/tb"
interface UserEditProps { interface UserEditProps {
user?: UserModel user?: UserModel
onCancel: () => void onCancel: () => void
onSave: () => void onSave: () => void
} }
export function UserEdit(props: UserEditProps) { export function UserEdit(props: UserEditProps) {
const form = useForm<AdminSaveUserRequest>({ const form = useForm<AdminSaveUserRequest>({
initialValues: props.user ?? { initialValues: props.user ?? {
name: "", name: "",
enabled: true, enabled: true,
admin: false, admin: false,
}, },
}) })
const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave }) const saveUser = useAsyncCallback(client.admin.saveUser, { onSuccess: props.onSave })
return ( return (
<> <>
{saveUser.error && ( {saveUser.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(saveUser.error)} /> <Alert messages={errorToStrings(saveUser.error)} />
</Box> </Box>
)} )}
<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" })} />
<Group justify="right"> <Group justify="right">
<Button variant="default" onClick={props.onCancel}> <Button variant="default" onClick={props.onCancel}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveUser.loading}>
<Trans>Save</Trans> <Trans>Save</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</> </>
) )
} }

View File

@@ -1,36 +1,36 @@
import { Input, Textarea } from "@mantine/core" import { Input, Textarea } from "@mantine/core"
import RichCodeEditor from "components/code/RichCodeEditor" import RichCodeEditor from "components/code/RichCodeEditor"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import { type ReactNode } from "react" import type { ReactNode } from "react"
interface CodeEditorProps { interface CodeEditorProps {
description?: ReactNode description?: ReactNode
language: "css" | "javascript" language: "css" | "javascript"
value?: string value?: string
onChange: (value: string | undefined) => void onChange: (value: string | undefined) => void
} }
export function CodeEditor(props: CodeEditorProps) { export function CodeEditor(props: CodeEditorProps) {
const mobile = useMobile() const mobile = useMobile()
return mobile ? ( return mobile ? (
// monaco mobile support is poor, fallback to textarea // monaco mobile support is poor, fallback to textarea
<Textarea <Textarea
autosize autosize
minRows={4} minRows={4}
maxRows={15} maxRows={15}
description={props.description} description={props.description}
styles={{ styles={{
input: { input: {
fontFamily: "monospace", fontFamily: "monospace",
}, },
}} }}
value={props.value} value={props.value}
onChange={e => props.onChange(e.currentTarget.value)} onChange={e => props.onChange(e.currentTarget.value)}
/> />
) : ( ) : (
<Input.Wrapper description={props.description}> <Input.Wrapper description={props.description}>
<RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} /> <RichCodeEditor height="30vh" language={props.language} value={props.value} onChange={props.onChange} />
</Input.Wrapper> </Input.Wrapper>
) )
} }

View File

@@ -1,52 +1,51 @@
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useColorScheme } from "hooks/useColorScheme" import { useColorScheme } from "hooks/useColorScheme"
import { useAsync } from "react-async-hook" import { useAsync } from "react-async-hook"
const init = async () => { const init = async () => {
window.MonacoEnvironment = { window.MonacoEnvironment = {
async getWorker(_, label) { async getWorker(_, label) {
let worker let worker: typeof import("*?worker")
if (label === "css") { if (label === "css") {
worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker") worker = await import("monaco-editor/esm/vs/language/css/css.worker?worker")
} else if (label === "javascript") { } else if (label === "javascript") {
worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker") worker = await import("monaco-editor/esm/vs/language/typescript/ts.worker?worker")
} else { } else {
worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker") worker = await import("monaco-editor/esm/vs/editor/editor.worker?worker")
} }
// eslint-disable-next-line new-cap return new worker.default()
return new worker.default() },
}, }
}
const monacoReact = await import("@monaco-editor/react")
const monacoReact = await import("@monaco-editor/react") const monaco = await import("monaco-editor")
const monaco = await import("monaco-editor") monacoReact.loader.config({ monaco })
monacoReact.loader.config({ monaco }) return monacoReact.Editor
return monacoReact.Editor }
}
interface RichCodeEditorProps {
interface RichCodeEditorProps { height: number | string
height: number | string language: "css" | "javascript"
language: "css" | "javascript" value?: string
value?: string onChange: (value: string | undefined) => void
onChange: (value: string | undefined) => void }
}
function RichCodeEditor(props: RichCodeEditorProps) {
function RichCodeEditor(props: RichCodeEditorProps) { const colorScheme = useColorScheme()
const colorScheme = useColorScheme() const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
const editorTheme = colorScheme === "dark" ? "vs-dark" : "light"
const { result: Editor } = useAsync(init, [])
const { result: Editor } = useAsync(init, []) if (!Editor) return <Loader />
if (!Editor) return <Loader /> return (
return ( <Editor
<Editor height={props.height}
height={props.height} defaultLanguage={props.language}
defaultLanguage={props.language} theme={editorTheme}
theme={editorTheme} options={{ minimap: { enabled: false } }}
options={{ minimap: { enabled: false } }} value={props.value}
value={props.value} onChange={props.onChange}
onChange={props.onChange} />
/> )
) }
}
export default RichCodeEditor
export default RichCodeEditor

View File

@@ -1,11 +1,11 @@
import { TypographyStylesProvider } from "@mantine/core" import { TypographyStylesProvider } from "@mantine/core"
import { type ReactNode } from "react" import type { ReactNode } from "react"
/** /**
* This component is used to provide basic styles to html typography elements. * This component is used to provide basic styles to html typography elements.
* *
* see https://mantine.dev/core/typography-styles-provider/ * see https://mantine.dev/core/typography-styles-provider/
*/ */
export const BasicHtmlStyles = (props: { children: ReactNode }) => { export const BasicHtmlStyles = (props: { children: ReactNode }) => {
return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider> return <TypographyStylesProvider pl={0}>{props.children}</TypographyStylesProvider>
} }

View File

@@ -1,103 +1,103 @@
import { Box, Mark } from "@mantine/core" import { Box, Mark } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils" import { calculatePlaceholderSize } from "app/utils"
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
import escapeStringRegexp from "escape-string-regexp" import escapeStringRegexp from "escape-string-regexp"
import { type ChildrenNode, Interweave, Matcher, type MatchResponse, type Node, type TransformCallback } from "interweave" import { type ChildrenNode, Interweave, type MatchResponse, Matcher, type Node, type TransformCallback } from "interweave"
import React from "react" import React from "react"
import { tss } from "tss" import { tss } from "tss"
export interface ContentProps { export interface ContentProps {
content: string content: string
highlight?: string highlight?: string
} }
const useStyles = tss.create(() => ({ const useStyles = tss.create(() => ({
content: { content: {
// break long links or long words // break long links or long words
overflowWrap: "anywhere", overflowWrap: "anywhere",
"& a": { "& a": {
color: "inherit", color: "inherit",
textDecoration: "underline", textDecoration: "underline",
}, },
"& iframe": { "& iframe": {
maxWidth: "100%", maxWidth: "100%",
}, },
"& pre, & code": { "& pre, & code": {
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}, },
}, },
})) }))
const transform: TransformCallback = node => { const transform: TransformCallback = node => {
if (node.tagName === "IMG") { if (node.tagName === "IMG") {
// show placeholders for loading img tags, this allows the entry to have its final height immediately // show placeholders for loading img tags, this allows the entry to have its final height immediately
const src = node.getAttribute("src") ?? undefined const src = node.getAttribute("src") ?? undefined
if (!src) return undefined if (!src) return undefined
const alt = node.getAttribute("alt") ?? "image" const alt = node.getAttribute("alt") ?? "image"
const title = node.getAttribute("title") ?? undefined const title = node.getAttribute("title") ?? undefined
const nodeWidth = node.getAttribute("width") const nodeWidth = node.getAttribute("width")
const nodeHeight = node.getAttribute("height") const nodeHeight = node.getAttribute("height")
const width = nodeWidth ? parseInt(nodeWidth, 10) : undefined const width = nodeWidth ? Number.parseInt(nodeWidth, 10) : undefined
const height = nodeHeight ? parseInt(nodeHeight, 10) : undefined const height = nodeHeight ? Number.parseInt(nodeHeight, 10) : undefined
const placeholderSize = calculatePlaceholderSize({ const placeholderSize = calculatePlaceholderSize({
width, width,
height, height,
maxWidth: Constants.layout.entryMaxWidth, maxWidth: Constants.layout.entryMaxWidth,
}) })
return ( return (
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={src} src={src}
alt={alt} alt={alt}
title={title} title={title}
width={width} width={width}
height="auto" height="auto"
placeholderWidth={placeholderSize.width} placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height} placeholderHeight={placeholderSize.height}
/> />
) )
} }
return undefined return undefined
} }
class HighlightMatcher extends Matcher { class HighlightMatcher extends Matcher {
private readonly search: string private readonly search: string
constructor(search: string) { constructor(search: string) {
super("highlight") super("highlight")
this.search = escapeStringRegexp(search) this.search = escapeStringRegexp(search)
} }
match(string: string): MatchResponse<unknown> | null { match(string: string): MatchResponse<unknown> | null {
const pattern = this.search.split(" ").join("|") const pattern = this.search.split(" ").join("|")
return this.doMatch(string, new RegExp(pattern, "i"), () => ({})) return this.doMatch(string, new RegExp(pattern, "i"), () => ({}))
} }
replaceWith(children: ChildrenNode): Node { replaceWith(children: ChildrenNode): Node {
return <Mark>{children}</Mark> return <Mark>{children}</Mark>
} }
asTag(): string { asTag(): string {
return "span" return "span"
} }
} }
// memoize component because Interweave is costly // memoize component because Interweave is costly
const Content = React.memo((props: ContentProps) => { const Content = React.memo((props: ContentProps) => {
const { classes } = useStyles() const { classes } = useStyles()
const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : [] const matchers = props.highlight ? [new HighlightMatcher(props.highlight)] : []
return ( return (
<BasicHtmlStyles> <BasicHtmlStyles>
<Box className={classes.content}> <Box className={classes.content}>
<Interweave content={props.content} transform={transform} matchers={matchers} /> <Interweave content={props.content} transform={transform} matchers={matchers} />
</Box> </Box>
</BasicHtmlStyles> </BasicHtmlStyles>
) )
}) })
Content.displayName = "Content" Content.displayName = "Content"
export { Content } export { Content }

View File

@@ -1,24 +1,29 @@
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
export function Enclosure(props: { enclosureType: string; enclosureUrl: string }) { export function Enclosure(props: {
const hasVideo = props.enclosureType.startsWith("video") enclosureType: string
const hasAudio = props.enclosureType.startsWith("audio") enclosureUrl: string
const hasImage = props.enclosureType.startsWith("image") }) {
const hasVideo = props.enclosureType.startsWith("video")
return ( const hasAudio = props.enclosureType.startsWith("audio")
<BasicHtmlStyles> const hasImage = props.enclosureType.startsWith("image")
{hasVideo && (
<video controls width="100%"> return (
<source src={props.enclosureUrl} type={props.enclosureType} /> <BasicHtmlStyles>
</video> {hasVideo && (
)} // biome-ignore lint/a11y/useMediaCaption: we don't have any captions for videos
{hasAudio && ( <video controls width="100%">
<audio controls> <source src={props.enclosureUrl} type={props.enclosureType} />
<source src={props.enclosureUrl} type={props.enclosureType} /> </video>
</audio> )}
)} {hasAudio && (
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />} // biome-ignore lint/a11y/useMediaCaption: we don't have any captions for audio
</BasicHtmlStyles> <audio controls>
) <source src={props.enclosureUrl} type={props.enclosureType} />
} </audio>
)}
{hasImage && <ImageWithPlaceholderWhileLoading src={props.enclosureUrl} alt="enclosure" />}
</BasicHtmlStyles>
)
}

View File

@@ -1,329 +1,329 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { openModal } from "@mantine/modals" import { openModal } from "@mantine/modals"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { type ExpendableEntry } from "app/entries/slice" import type { ExpendableEntry } from "app/entries/slice"
import { import {
loadMoreEntries, loadMoreEntries,
markAllEntries, markAllEntries,
markEntry, markEntry,
reloadEntries, reloadEntries,
selectEntry, selectEntry,
selectNextEntry, selectNextEntry,
selectPreviousEntry, selectPreviousEntry,
starEntry, starEntry,
} from "app/entries/thunks" } from "app/entries/thunks"
import { redirectToRootCategory } from "app/redirect/thunks" import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { toggleSidebar } from "app/tree/slice" import { toggleSidebar } from "app/tree/slice"
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp" import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMousetrap } from "hooks/useMousetrap" import { useMousetrap } from "hooks/useMousetrap"
import { useViewMode } from "hooks/useViewMode" import { useEffect } from "react"
import { useEffect } from "react" import { useContextMenu } from "react-contexify"
import { useContextMenu } from "react-contexify" import InfiniteScroll from "react-infinite-scroller"
import InfiniteScroll from "react-infinite-scroller" import { throttle } from "throttle-debounce"
import { throttle } from "throttle-debounce" import { FeedEntry } from "./FeedEntry"
import { FeedEntry } from "./FeedEntry"
export function FeedEntries() {
export function FeedEntries() { const source = useAppSelector(state => state.entries.source)
const source = useAppSelector(state => state.entries.source) const entries = useAppSelector(state => state.entries.entries)
const entries = useAppSelector(state => state.entries.entries) const entriesTimestamp = useAppSelector(state => state.entries.timestamp)
const entriesTimestamp = useAppSelector(state => state.entries.timestamp) const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId)
const selectedEntryId = useAppSelector(state => state.entries.selectedEntryId) const hasMore = useAppSelector(state => state.entries.hasMore)
const hasMore = useAppSelector(state => state.entries.hasMore) const loading = useAppSelector(state => state.entries.loading)
const loading = useAppSelector(state => state.entries.loading) const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry)
const scrollingToEntry = useAppSelector(state => state.entries.scrollingToEntry) const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible)
const sidebarVisible = useAppSelector(state => state.tree.sidebarVisible) const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu) const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
const { viewMode } = useViewMode() const dispatch = useAppDispatch()
const dispatch = useAppDispatch() const { openLinkInBackgroundTab } = useBrowserExtension()
const { openLinkInBackgroundTab } = useBrowserExtension()
const selectedEntry = entries.find(e => e.id === selectedEntryId)
const selectedEntry = entries.find(e => e.id === selectedEntryId)
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
const headerClicked = (entry: ExpendableEntry, event: React.MouseEvent) => { const middleClick = event.button === 1 || event.ctrlKey || event.metaKey
const middleClick = event.button === 1 || event.ctrlKey || event.metaKey if (middleClick || viewMode === "expanded") {
if (middleClick || viewMode === "expanded") { dispatch(markEntry({ entry, read: true }))
dispatch(markEntry({ entry, read: true })) } else if (event.button === 0) {
} else if (event.button === 0) { // main click
// main click // don't trigger the link
// don't trigger the link event.preventDefault()
event.preventDefault()
dispatch(
dispatch( selectEntry({
selectEntry({ entry,
entry, expand: !entry.expanded,
expand: !entry.expanded, markAsRead: !entry.expanded,
markAsRead: !entry.expanded, scrollToEntry: true,
scrollToEntry: true, })
}) )
) }
} }
}
const contextMenu = useContextMenu()
const contextMenu = useContextMenu() const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => {
const headerRightClicked = (entry: ExpendableEntry, event: React.MouseEvent) => { if (event.shiftKey || !customContextMenu) return
if (event.shiftKey || !customContextMenu) return
event.preventDefault()
event.preventDefault() contextMenu.show({
contextMenu.show({ id: Constants.dom.entryContextMenuId(entry),
id: Constants.dom.entryContextMenuId(entry), event,
event, })
}) }
}
const bodyClicked = (entry: ExpendableEntry) => {
const bodyClicked = (entry: ExpendableEntry) => { if (viewMode !== "expanded") return
if (viewMode !== "expanded") return
// entry is already selected
// entry is already selected if (entry.id === selectedEntryId) return
if (entry.id === selectedEntryId) return
dispatch(
dispatch( selectEntry({
selectEntry({ entry,
entry, expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) }
}
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
const swipedLeft = async (entry: ExpendableEntry) => await dispatch(markEntry({ entry, read: !entry.read }))
// close context menu on scroll
// close context menu on scroll useEffect(() => {
useEffect(() => { const listener = throttle(100, () => contextMenu.hideAll())
const listener = throttle(100, () => contextMenu.hideAll()) window.addEventListener("scroll", listener)
window.addEventListener("scroll", listener) return () => window.removeEventListener("scroll", listener)
return () => window.removeEventListener("scroll", listener) }, [contextMenu])
}, [contextMenu])
useEffect(() => {
useEffect(() => { const listener = throttle(100, () => {
const listener = throttle(100, () => { if (viewMode !== "expanded") return
if (viewMode !== "expanded") return if (scrollingToEntry) return
if (scrollingToEntry) return
const currentEntry = entries
const currentEntry = entries // use slice to get a copy of the array because reverse mutates the array in-place
// use slice to get a copy of the array because reverse mutates the array in-place .slice()
.slice() .reverse()
.reverse() .find(e => {
.find(e => { 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) { dispatch(
dispatch( selectEntry({
selectEntry({ entry: currentEntry,
entry: currentEntry, expand: false,
expand: false, markAsRead: !!scrollMarks,
markAsRead: !!scrollMarks, scrollToEntry: false,
scrollToEntry: false, })
}) )
) }
} })
}) window.addEventListener("scroll", listener)
window.addEventListener("scroll", listener) return () => window.removeEventListener("scroll", listener)
return () => window.removeEventListener("scroll", listener) }, [dispatch, entries, viewMode, scrollMarks, scrollingToEntry])
}, [dispatch, contextMenu, entries, viewMode, scrollMarks, scrollingToEntry])
useMousetrap("r", async () => await dispatch(reloadEntries()))
useMousetrap("r", async () => await dispatch(reloadEntries())) useMousetrap(
useMousetrap( "j",
"j", async () =>
async () => await dispatch(
await dispatch( selectNextEntry({
selectNextEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) )
) useMousetrap(
useMousetrap( "n",
"n", async () =>
async () => await dispatch(
await dispatch( selectNextEntry({
selectNextEntry({ expand: false,
expand: false, markAsRead: false,
markAsRead: false, scrollToEntry: true,
scrollToEntry: true, })
}) )
) )
) useMousetrap(
useMousetrap( "k",
"k", async () =>
async () => await dispatch(
await dispatch( selectPreviousEntry({
selectPreviousEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) )
) useMousetrap(
useMousetrap( "p",
"p", async () =>
async () => await dispatch(
await dispatch( selectPreviousEntry({
selectPreviousEntry({ expand: false,
expand: false, markAsRead: false,
markAsRead: false, scrollToEntry: true,
scrollToEntry: true, })
}) )
) )
) useMousetrap("space", () => {
useMousetrap("space", () => { if (selectedEntry) {
if (selectedEntry) { if (selectedEntry.expanded) {
if (selectedEntry.expanded) { const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) if (entryElement && Constants.layout.isBottomVisible(entryElement)) {
if (entryElement && Constants.layout.isBottomVisible(entryElement)) { dispatch(
dispatch( selectNextEntry({
selectNextEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) } else {
} else { window.scrollTo({
window.scrollTo({ top: window.scrollY + document.documentElement.clientHeight * 0.8,
top: window.scrollY + document.documentElement.clientHeight * 0.8, behavior: "smooth",
behavior: "smooth", })
}) }
} } else {
} else { dispatch(
dispatch( selectEntry({
selectEntry({ entry: selectedEntry,
entry: selectedEntry, expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) }
} } else {
} else { dispatch(
dispatch( selectNextEntry({
selectNextEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) }
} })
}) useMousetrap("shift+space", () => {
useMousetrap("shift+space", () => { if (selectedEntry) {
if (selectedEntry) { if (selectedEntry.expanded) {
if (selectedEntry.expanded) { const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry))
const entryElement = document.getElementById(Constants.dom.entryId(selectedEntry)) if (entryElement && Constants.layout.isTopVisible(entryElement)) {
if (entryElement && Constants.layout.isTopVisible(entryElement)) { dispatch(
dispatch( selectPreviousEntry({
selectPreviousEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) } else {
} else { window.scrollTo({
window.scrollTo({ top: window.scrollY - document.documentElement.clientHeight * 0.8,
top: window.scrollY - document.documentElement.clientHeight * 0.8, behavior: "smooth",
behavior: "smooth", })
}) }
} } else {
} else { dispatch(
dispatch( selectPreviousEntry({
selectPreviousEntry({ expand: true,
expand: true, markAsRead: true,
markAsRead: true, scrollToEntry: true,
scrollToEntry: true, })
}) )
) }
} }
} })
}) useMousetrap(["o", "enter"], () => {
useMousetrap(["o", "enter"], () => { // toggle expanded status
// toggle expanded status if (!selectedEntry) return
if (!selectedEntry) return dispatch(
dispatch( selectEntry({
selectEntry({ entry: selectedEntry,
entry: selectedEntry, expand: !selectedEntry.expanded,
expand: !selectedEntry.expanded, markAsRead: !selectedEntry.expanded,
markAsRead: !selectedEntry.expanded, scrollToEntry: true,
scrollToEntry: true, })
}) )
) })
}) useMousetrap("v", () => {
useMousetrap("v", () => { // open tab in foreground
// open tab in foreground if (!selectedEntry) return
if (!selectedEntry) return window.open(selectedEntry.url, "_blank", "noreferrer")
window.open(selectedEntry.url, "_blank", "noreferrer") })
}) useMousetrap("b", () => {
useMousetrap("b", () => { if (!selectedEntry) return
if (!selectedEntry) return openLinkInBackgroundTab(selectedEntry.url)
openLinkInBackgroundTab(selectedEntry.url) })
}) useMousetrap("m", () => {
useMousetrap("m", () => { // toggle read status
// toggle read status if (!selectedEntry) return
if (!selectedEntry) return dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read }))
dispatch(markEntry({ entry: selectedEntry, read: !selectedEntry.read })) })
}) useMousetrap("s", () => {
useMousetrap("s", () => { // toggle starred status
// toggle starred status if (!selectedEntry) return
if (!selectedEntry) return dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred }))
dispatch(starEntry({ entry: selectedEntry, starred: !selectedEntry.starred })) })
}) useMousetrap("shift+a", () => {
useMousetrap("shift+a", () => { // mark all entries as read
// mark all entries as read dispatch(
dispatch( markAllEntries({
markAllEntries({ sourceType: source.type,
sourceType: source.type, req: {
req: { id: source.id,
id: source.id, read: true,
read: true, olderThan: Date.now(),
olderThan: Date.now(), insertedBefore: entriesTimestamp,
insertedBefore: entriesTimestamp, },
}, })
}) )
) })
}) useMousetrap("g a", async () => await dispatch(redirectToRootCategory()))
useMousetrap("g a", async () => await dispatch(redirectToRootCategory())) useMousetrap("f", () => dispatch(toggleSidebar()))
useMousetrap("f", () => dispatch(toggleSidebar())) useMousetrap("?", () =>
useMousetrap("?", () => openModal({
openModal({ title: <Trans>Keyboard shortcuts</Trans>,
title: <Trans>Keyboard shortcuts</Trans>, size: "xl",
size: "xl", children: <KeyboardShortcutsHelp />,
children: <KeyboardShortcutsHelp />, })
}) )
)
return (
return ( <InfiniteScroll
<InfiniteScroll id="entries"
id="entries" className={`view-mode-${viewMode}`}
className={`view-mode-${viewMode}`} initialLoad={false}
initialLoad={false} loadMore={async () => await (!loading && dispatch(loadMoreEntries()))}
loadMore={async () => await (!loading && dispatch(loadMoreEntries()))} hasMore={hasMore}
hasMore={hasMore} loader={<Box key={0}>{loading && <Loader />}</Box>}
loader={<Box key={0}>{loading && <Loader />}</Box>} >
> {entries.map(entry => (
{entries.map(entry => ( <article
<div key={entry.id}
key={entry.id} ref={el => {
ref={el => { if (el) el.id = Constants.dom.entryId(entry)
if (el) el.id = Constants.dom.entryId(entry) }}
}} data-id={entry.id}
> >
<FeedEntry <FeedEntry
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)}
onBodyClick={() => bodyClicked(entry)} onBodyClick={() => bodyClicked(entry)}
onSwipedLeft={async () => await swipedLeft(entry)} onSwipedLeft={async () => await swipedLeft(entry)}
/> />
</div> </article>
))} ))}
</InfiniteScroll> </InfiniteScroll>
) )
} }

View File

@@ -1,167 +1,190 @@
import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core" import { Box, Divider, type MantineRadius, type MantineSpacing, Paper } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { type Entry, type ViewMode } from "app/types" import { useAppSelector } from "app/store"
import { useViewMode } from "hooks/useViewMode" import type { Entry, ViewMode } from "app/types"
import React from "react" import { FeedEntryCompactHeader } from "components/content/header/FeedEntryCompactHeader"
import { useSwipeable } from "react-swipeable" import { FeedEntryHeader } from "components/content/header/FeedEntryHeader"
import { tss } from "tss" import { useMobile } from "hooks/useMobile"
import { FeedEntryBody } from "./FeedEntryBody" import type React from "react"
import { FeedEntryCompactHeader } from "./FeedEntryCompactHeader" import { useSwipeable } from "react-swipeable"
import { FeedEntryContextMenu } from "./FeedEntryContextMenu" import { tss } from "tss"
import { FeedEntryFooter } from "./FeedEntryFooter" import { FeedEntryBody } from "./FeedEntryBody"
import { FeedEntryHeader } from "./FeedEntryHeader" import { FeedEntryContextMenu } from "./FeedEntryContextMenu"
import { FeedEntryFooter } from "./FeedEntryFooter"
interface FeedEntryProps {
entry: Entry interface FeedEntryProps {
expanded: boolean entry: Entry
selected: boolean expanded: boolean
showSelectionIndicator: boolean selected: boolean
maxWidth?: number showSelectionIndicator: boolean
onHeaderClick: (e: React.MouseEvent) => void maxWidth?: number
onHeaderRightClick: (e: React.MouseEvent) => void onHeaderClick: (e: React.MouseEvent) => void
onBodyClick: (e: React.MouseEvent) => void onHeaderRightClick: (e: React.MouseEvent) => void
onSwipedLeft: () => void onBodyClick: (e: React.MouseEvent) => void
} onSwipedLeft: () => void
}
const useStyles = tss
.withParams<{ const useStyles = tss
read: boolean .withParams<{
expanded: boolean read: boolean
viewMode: ViewMode expanded: boolean
rtl: boolean viewMode: ViewMode
showSelectionIndicator: boolean rtl: boolean
maxWidth?: number showSelectionIndicator: boolean
}>() maxWidth?: number
.create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => { }>()
let backgroundColor .create(({ theme, colorScheme, read, expanded, viewMode, rtl, showSelectionIndicator, maxWidth }) => {
if (colorScheme === "dark") { let backgroundColor: string
backgroundColor = read ? "inherit" : theme.colors.dark[5] if (colorScheme === "dark") {
} else { backgroundColor = read ? "inherit" : theme.colors.dark[5]
backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit" } else {
} backgroundColor = read && !expanded ? theme.colors.gray[0] : "inherit"
}
let marginY = 10
if (viewMode === "title") { let marginY = 10
marginY = 2 if (viewMode === "title") {
} else if (viewMode === "cozy") { marginY = 2
marginY = 6 } else if (viewMode === "cozy") {
} marginY = 6
}
let mobileMarginY = 6
if (viewMode === "title") { let mobileMarginY = 6
mobileMarginY = 2 if (viewMode === "title") {
} else if (viewMode === "cozy") { mobileMarginY = 2
mobileMarginY = 4 } else if (viewMode === "cozy") {
} mobileMarginY = 4
}
let backgroundHoverColor = backgroundColor
if (!expanded && !read) { let backgroundHoverColor = backgroundColor
backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1] if (!expanded && !read) {
} backgroundHoverColor = colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[1]
}
let paperBorderLeftColor
if (showSelectionIndicator) { let paperBorderLeftColor = ""
const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6] if (showSelectionIndicator) {
paperBorderLeftColor = `${borderLeftColor} !important` const borderLeftColor = colorScheme === "dark" ? theme.colors[theme.primaryColor][4] : theme.colors[theme.primaryColor][6]
} paperBorderLeftColor = `${borderLeftColor} !important`
}
return {
paper: { return {
backgroundColor, paper: {
borderLeftColor: paperBorderLeftColor, backgroundColor,
marginTop: marginY, borderLeftColor: paperBorderLeftColor,
marginBottom: marginY, marginTop: marginY,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: { marginBottom: marginY,
marginTop: mobileMarginY, [`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
marginBottom: mobileMarginY, marginTop: mobileMarginY,
}, marginBottom: mobileMarginY,
"@media (hover: hover)": { },
"&:hover": { "@media (hover: hover)": {
backgroundColor: backgroundHoverColor, "&:hover": {
}, backgroundColor: backgroundHoverColor,
}, },
}, },
headerLink: { },
color: "inherit", headerLink: {
textDecoration: "none", color: "inherit",
}, textDecoration: "none",
body: { },
direction: rtl ? "rtl" : "ltr", body: {
maxWidth: maxWidth ?? "100%", direction: rtl ? "rtl" : "ltr",
}, maxWidth: maxWidth ?? "100%",
} },
}) }
})
export function FeedEntry(props: FeedEntryProps) {
const { viewMode } = useViewMode() export function FeedEntry(props: FeedEntryProps) {
const { classes, cx } = useStyles({ const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
read: props.entry.read, const { classes, cx } = useStyles({
expanded: props.expanded, read: props.entry.read,
viewMode, expanded: props.expanded,
rtl: props.entry.rtl, viewMode,
showSelectionIndicator: props.showSelectionIndicator, rtl: props.entry.rtl,
maxWidth: props.maxWidth, showSelectionIndicator: props.showSelectionIndicator,
}) maxWidth: props.maxWidth,
})
const swipeHandlers = useSwipeable({
onSwipedLeft: props.onSwipedLeft, const externalLinkDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
}) const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
const mobile = useMobile()
let paddingX: MantineSpacing = "xs"
if (viewMode === "title" || viewMode === "cozy") paddingX = 6 const showExternalLinkIcon =
externalLinkDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(externalLinkDisplayMode)
let paddingY: MantineSpacing = "xs" const showStarIcon =
if (viewMode === "title") { props.entry.markable && starIconDisplayMode && ["always", mobile ? "on_mobile" : "on_desktop"].includes(starIconDisplayMode)
paddingY = 4
} else if (viewMode === "cozy") { const swipeHandlers = useSwipeable({
paddingY = 8 onSwipedLeft: props.onSwipedLeft,
} })
let borderRadius: MantineRadius = "sm" let paddingX: MantineSpacing = "xs"
if (viewMode === "title") { if (viewMode === "title" || viewMode === "cozy") paddingX = 6
borderRadius = 0
} else if (viewMode === "cozy") { let paddingY: MantineSpacing = "xs"
borderRadius = "xs" if (viewMode === "title") {
} paddingY = 4
} else if (viewMode === "cozy") {
const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy") paddingY = 8
return ( }
<Paper
withBorder let borderRadius: MantineRadius = "sm"
radius={borderRadius} if (viewMode === "title") {
className={cx(classes.paper, { borderRadius = 0
read: props.entry.read, } else if (viewMode === "cozy") {
unread: !props.entry.read, borderRadius = "xs"
expanded: props.expanded, }
selected: props.selected,
"show-selection-indicator": props.showSelectionIndicator, const compactHeader = !props.expanded && (viewMode === "title" || viewMode === "cozy")
})} return (
> <Paper
<a withBorder
className={classes.headerLink} radius={borderRadius}
href={props.entry.url} className={cx(classes.paper, {
target="_blank" read: props.entry.read,
rel="noreferrer" unread: !props.entry.read,
onClick={props.onHeaderClick} expanded: props.expanded,
onAuxClick={props.onHeaderClick} selected: props.selected,
onContextMenu={props.onHeaderRightClick} "show-selection-indicator": props.showSelectionIndicator,
> })}
<Box px={paddingX} py={paddingY} {...swipeHandlers}> >
{compactHeader && <FeedEntryCompactHeader entry={props.entry} />} <a
{!compactHeader && <FeedEntryHeader entry={props.entry} expanded={props.expanded} />} className={classes.headerLink}
</Box> href={props.entry.url}
</a> target="_blank"
{props.expanded && ( rel="noreferrer"
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}> onClick={props.onHeaderClick}
<Box className={classes.body}> onAuxClick={props.onHeaderClick}
<FeedEntryBody entry={props.entry} /> onContextMenu={props.onHeaderRightClick}
</Box> >
<Divider variant="dashed" my={paddingY} /> <Box px={paddingX} py={paddingY} {...swipeHandlers}>
<FeedEntryFooter entry={props.entry} /> {compactHeader && (
</Box> <FeedEntryCompactHeader
)} entry={props.entry}
showStarIcon={showStarIcon}
<FeedEntryContextMenu entry={props.entry} /> showExternalLinkIcon={showExternalLinkIcon}
</Paper> />
) )}
} {!compactHeader && (
<FeedEntryHeader
entry={props.entry}
expanded={props.expanded}
showStarIcon={showStarIcon}
showExternalLinkIcon={showExternalLinkIcon}
/>
)}
</Box>
</a>
{props.expanded && (
<Box px={paddingX} pb={paddingY} onClick={props.onBodyClick}>
<Box className={classes.body}>
<FeedEntryBody entry={props.entry} />
</Box>
<Divider variant="dashed" my={paddingY} />
<FeedEntryFooter entry={props.entry} />
</Box>
)}
<FeedEntryContextMenu entry={props.entry} />
</Paper>
)
}

View File

@@ -1,37 +1,37 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { Content } from "./Content" import { Content } from "./Content"
import { Enclosure } from "./Enclosure" import { Enclosure } from "./Enclosure"
import { Media } from "./Media" import { Media } from "./Media"
export interface FeedEntryBodyProps { export interface FeedEntryBodyProps {
entry: Entry entry: Entry
} }
export function FeedEntryBody(props: FeedEntryBodyProps) { export function FeedEntryBody(props: FeedEntryBodyProps) {
const search = useAppSelector(state => state.entries.search) const search = useAppSelector(state => state.entries.search)
return ( return (
<Box> <Box>
<Box> <Box>
<Content content={props.entry.content} highlight={search} /> <Content content={props.entry.content} highlight={search} />
</Box> </Box>
{props.entry.enclosureType && props.entry.enclosureUrl && ( {props.entry.enclosureType && props.entry.enclosureUrl && (
<Box pt="md"> <Box pt="md">
<Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} /> <Enclosure enclosureType={props.entry.enclosureType} enclosureUrl={props.entry.enclosureUrl} />
</Box> </Box>
)} )}
{/* show media only if we don't have content to avoid duplicate content */} {/* show media only if we don't have content to avoid duplicate content */}
{!props.entry.content && props.entry.mediaThumbnailUrl && ( {!props.entry.content && props.entry.mediaThumbnailUrl && (
<Box pt="md"> <Box pt="md">
<Media <Media
thumbnailUrl={props.entry.mediaThumbnailUrl} thumbnailUrl={props.entry.mediaThumbnailUrl}
thumbnailWidth={props.entry.mediaThumbnailWidth} thumbnailWidth={props.entry.mediaThumbnailWidth}
thumbnailHeight={props.entry.mediaThumbnailHeight} thumbnailHeight={props.entry.mediaThumbnailHeight}
description={props.entry.mediaDescription} description={props.entry.mediaDescription}
/> />
</Box> </Box>
)} )}
</Box> </Box>
) )
} }

View File

@@ -1,101 +1,103 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Group } from "@mantine/core" import { Group } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks" import { markEntriesUpToEntry, markEntry, starEntry } from "app/entries/thunks"
import { redirectToFeed } from "app/redirect/thunks" import { redirectToFeed } from "app/redirect/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"
import { truncate } from "app/utils" import { truncate } from "app/utils"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useColorScheme } from "hooks/useColorScheme" import { useColorScheme } from "hooks/useColorScheme"
import { Item, Menu, Separator } from "react-contexify" import { Item, Menu, Separator } from "react-contexify"
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbRss, TbStar, TbStarOff } from "react-icons/tb" import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbRss, TbStar, TbStarOff } from "react-icons/tb"
import { tss } from "tss" import { tss } from "tss"
interface FeedEntryContextMenuProps { interface FeedEntryContextMenuProps {
entry: Entry entry: Entry
} }
const iconSize = 16 const iconSize = 16
const useStyles = tss.create(({ theme, colorScheme }) => ({ const useStyles = tss.create(({ theme, colorScheme }) => ({
menu: { menu: {
// apply mantine theme from MenuItem.styles.ts // apply mantine theme from MenuItem.styles.ts
fontSize: theme.fontSizes.sm, fontSize: theme.fontSizes.sm,
"--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`, "--contexify-item-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
"--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`, "--contexify-activeItem-color": `${colorScheme === "dark" ? theme.colors.dark[0] : theme.black} !important`,
"--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`, "--contexify-activeItem-bgColor": `${colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]} !important`,
}, },
})) }))
export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) { export function FeedEntryContextMenu(props: FeedEntryContextMenuProps) {
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const { classes } = useStyles() const { classes } = useStyles()
const sourceType = useAppSelector(state => state.entries.source.type) const sourceType = useAppSelector(state => state.entries.source.type)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
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")
dispatch(markEntry({ entry: props.entry, read: true })) dispatch(markEntry({ entry: props.entry, read: true }))
}} }}
> >
<Group> <Group>
<TbExternalLink size={iconSize} /> <TbExternalLink size={iconSize} />
<Trans>Open link in new tab</Trans> <Trans>Open link in new tab</Trans>
</Group> </Group>
</Item> </Item>
<Item <Item
onClick={() => { onClick={() => {
openLinkInBackgroundTab(props.entry.url) openLinkInBackgroundTab(props.entry.url)
dispatch(markEntry({ entry: props.entry, read: true })) dispatch(markEntry({ entry: props.entry, read: true }))
}} }}
> >
<Group> <Group>
<TbExternalLink size={iconSize} /> <TbExternalLink size={iconSize} />
<Trans>Open link in new background tab</Trans> <Trans>Open link in new background tab</Trans>
</Group> </Group>
</Item> </Item>
<Separator /> <Separator />
<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 }))}> {props.entry.markable && (
<Group> <Item onClick={async () => await dispatch(markEntry({ entry: props.entry, read: !props.entry.read }))}>
{props.entry.read ? <TbEyeOff size={iconSize} /> : <TbEyeCheck size={iconSize} />} <Group>
{props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>} {props.entry.read ? <TbMail size={iconSize} /> : <TbMailOpened size={iconSize} />}
</Group> {props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>}
</Item> </Group>
<Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}> </Item>
<Group> )}
<TbArrowBarToDown size={iconSize} /> <Item onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}>
<Trans>Mark as read up to here</Trans> <Group>
</Group> <TbArrowBarToDown size={iconSize} />
</Item> <Trans>Mark as read up to here</Trans>
</Group>
{sourceType === "category" && ( </Item>
<>
<Separator /> {sourceType === "category" && (
<>
<Item <Separator />
onClick={() => {
dispatch(redirectToFeed(props.entry.feedId)) <Item
}} onClick={() => {
> dispatch(redirectToFeed(props.entry.feedId))
<Group> }}
<TbRss size={iconSize} /> >
<Trans>Go to {truncate(props.entry.feedName, 30)}</Trans> <Group>
</Group> <TbRss size={iconSize} />
</Item> <Trans>Go to {truncate(props.entry.feedName, 30)}</Trans>
</> </Group>
)} </Item>
</Menu> </>
) )}
} </Menu>
)
}

View File

@@ -1,107 +1,104 @@
import { t, Trans } from "@lingui/macro" import { msg } from "@lingui/macro"
import { Group, Indicator, Popover, TagsInput } from "@mantine/core" import { useLingui } from "@lingui/react"
import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks" import { Group, Indicator, Popover, TagsInput } from "@mantine/core"
import { useAppDispatch, useAppSelector } from "app/store" import { markEntriesUpToEntry, markEntry, starEntry, tagEntry } from "app/entries/thunks"
import { type Entry } from "app/types" import { useAppDispatch, useAppSelector } from "app/store"
import { ActionButton } from "components/ActionButton" import type { Entry } from "app/types"
import { useActionButton } from "hooks/useActionButton" import { ActionButton } from "components/ActionButton"
import { useMobile } from "hooks/useMobile" import { useActionButton } from "hooks/useActionButton"
import { TbArrowBarToDown, TbExternalLink, TbEyeCheck, TbEyeOff, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb" import { useMobile } from "hooks/useMobile"
import { ShareButtons } from "./ShareButtons" import { TbArrowBarToDown, TbExternalLink, TbMail, TbMailOpened, TbShare, TbStar, TbStarOff, TbTag } from "react-icons/tb"
import { ShareButtons } from "./ShareButtons"
interface FeedEntryFooterProps {
entry: Entry interface FeedEntryFooterProps {
} entry: Entry
}
export function FeedEntryFooter(props: FeedEntryFooterProps) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) export function FeedEntryFooter(props: FeedEntryFooterProps) {
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const mobile = useMobile() const mobile = useMobile()
const { spacing } = useActionButton() const { spacing } = useActionButton()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { _ } = useLingui()
const showSharingButtons = sharingSettings && Object.values(sharingSettings).some(v => v)
const readStatusButtonClicked = async () =>
const readStatusButtonClicked = async () => await dispatch(
await dispatch( markEntry({
markEntry({ entry: props.entry,
entry: props.entry, read: !props.entry.read,
read: !props.entry.read, })
}) )
) const onTagsChange = async (values: string[]) =>
const onTagsChange = async (values: string[]) => await dispatch(
await dispatch( tagEntry({
tagEntry({ entryId: +props.entry.id,
entryId: +props.entry.id, tags: values,
tags: values, })
}) )
)
return (
return ( <Group justify="space-between">
<Group justify="space-between"> <Group gap={spacing}>
<Group gap={spacing}> {props.entry.markable && (
{props.entry.markable && ( <ActionButton
<ActionButton icon={props.entry.read ? <TbMail size={18} /> : <TbMailOpened size={18} />}
icon={props.entry.read ? <TbEyeOff size={18} /> : <TbEyeCheck size={18} />} label={props.entry.read ? msg`Keep unread` : msg`Mark as read`}
label={props.entry.read ? <Trans>Keep unread</Trans> : <Trans>Mark as read</Trans>} onClick={readStatusButtonClicked}
onClick={readStatusButtonClicked} />
/> )}
)} <ActionButton
<ActionButton icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />}
icon={props.entry.starred ? <TbStarOff size={18} /> : <TbStar size={18} />} label={props.entry.starred ? msg`Unstar` : msg`Star`}
label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} onClick={async () =>
onClick={async () => await dispatch(
await dispatch( starEntry({
starEntry({ entry: props.entry,
entry: props.entry, starred: !props.entry.starred,
starred: !props.entry.starred, })
}) )
) }
} />
/>
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}>
{showSharingButtons && ( <Popover.Target>
<Popover withArrow withinPortal shadow="md" closeOnClickOutside={!mobile}> <ActionButton icon={<TbShare size={18} />} label={msg`Share`} />
<Popover.Target> </Popover.Target>
<ActionButton icon={<TbShare size={18} />} label={<Trans>Share</Trans>} /> <Popover.Dropdown>
</Popover.Target> <ShareButtons url={props.entry.url} description={props.entry.title} />
<Popover.Dropdown> </Popover.Dropdown>
<ShareButtons url={props.entry.url} description={props.entry.title} /> </Popover>
</Popover.Dropdown>
</Popover> {tags && (
)} <Popover withArrow shadow="md" closeOnClickOutside={!mobile}>
<Popover.Target>
{tags && ( <Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}>
<Popover withArrow shadow="md" closeOnClickOutside={!mobile}> <ActionButton icon={<TbTag size={18} />} label={msg`Tags`} />
<Popover.Target> </Indicator>
<Indicator label={props.entry.tags.length} disabled={props.entry.tags.length === 0} inline size={16}> </Popover.Target>
<ActionButton icon={<TbTag size={18} />} label={<Trans>Tags</Trans>} /> <Popover.Dropdown>
</Indicator> <TagsInput
</Popover.Target> placeholder={_(msg`Tags`)}
<Popover.Dropdown> data={tags}
<TagsInput value={props.entry.tags}
placeholder={t`Tags`} onChange={onTagsChange}
data={tags} comboboxProps={{
value={props.entry.tags} withinPortal: false,
onChange={onTagsChange} }}
comboboxProps={{ />
withinPortal: false, </Popover.Dropdown>
}} </Popover>
/> )}
</Popover.Dropdown>
</Popover> <a href={props.entry.url} target="_blank" rel="noreferrer">
)} <ActionButton icon={<TbExternalLink size={18} />} label={msg`Open link`} />
</a>
<a href={props.entry.url} target="_blank" rel="noreferrer"> </Group>
<ActionButton icon={<TbExternalLink size={18} />} label={<Trans>Open link</Trans>} />
</a> <ActionButton
</Group> icon={<TbArrowBarToDown size={18} />}
label={msg`Mark as read up to here`}
<ActionButton onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))}
icon={<TbArrowBarToDown size={18} />} />
label={<Trans>Mark as read up to here</Trans>} </Group>
onClick={async () => await dispatch(markEntriesUpToEntry(props.entry))} )
/> }
</Group>
)
}

View File

@@ -1,21 +1,21 @@
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
export interface FeedFaviconProps { export interface FeedFaviconProps {
url: string url: string
size?: number size?: number
} }
export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) { export function FeedFavicon({ url, size = 18 }: FeedFaviconProps) {
return ( return (
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={url} src={url}
alt="feed favicon" alt="feed favicon"
width={size} width={size}
height={size} height={size}
placeholderWidth={size} placeholderWidth={size}
placeholderHeight={size} placeholderHeight={size}
placeholderBackgroundColor="inherit" placeholderBackgroundColor="inherit"
placeholderIconSize={size} placeholderIconSize={size}
/> />
) )
} }

View File

@@ -1,40 +1,40 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { calculatePlaceholderSize } from "app/utils" import { calculatePlaceholderSize } from "app/utils"
import { BasicHtmlStyles } from "components/content/BasicHtmlStyles" import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading" import { BasicHtmlStyles } from "components/content/BasicHtmlStyles"
import { Content } from "./Content" import { Content } from "./Content"
export interface MediaProps { export interface MediaProps {
thumbnailUrl: string thumbnailUrl: string
thumbnailWidth?: number thumbnailWidth?: number
thumbnailHeight?: number thumbnailHeight?: number
description?: string description?: string
} }
export function Media(props: MediaProps) { export function Media(props: MediaProps) {
const width = props.thumbnailWidth const width = props.thumbnailWidth
const height = props.thumbnailHeight const height = props.thumbnailHeight
const placeholderSize = calculatePlaceholderSize({ const placeholderSize = calculatePlaceholderSize({
width, width,
height, height,
maxWidth: Constants.layout.entryMaxWidth, maxWidth: Constants.layout.entryMaxWidth,
}) })
return ( return (
<BasicHtmlStyles> <BasicHtmlStyles>
<ImageWithPlaceholderWhileLoading <ImageWithPlaceholderWhileLoading
src={props.thumbnailUrl} src={props.thumbnailUrl}
alt="media thumbnail" alt="media thumbnail"
width={props.thumbnailWidth} width={props.thumbnailWidth}
height={props.thumbnailHeight} height={props.thumbnailHeight}
placeholderWidth={placeholderSize.width} placeholderWidth={placeholderSize.width}
placeholderHeight={placeholderSize.height} placeholderHeight={placeholderSize.height}
/> />
{props.description && ( {props.description && (
<Box pt="md"> <Box pt="md">
<Content content={props.description} /> <Content content={props.description} />
</Box> </Box>
)} )}
</BasicHtmlStyles> </BasicHtmlStyles>
) )
} }

View File

@@ -1,62 +1,113 @@
import { ActionIcon, Box, SimpleGrid } from "@mantine/core" import { Trans } from "@lingui/macro"
import { Constants } from "app/constants" import { ActionIcon, Box, CopyButton, Divider, SimpleGrid } from "@mantine/core"
import { useAppSelector } from "app/store" import { Constants } from "app/constants"
import { type SharingSettings } from "app/types" import { useAppSelector } from "app/store"
import { type IconType } from "react-icons" import type { SharingSettings } from "app/types"
import { tss } from "tss" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
type Color = `#${string}` import type { IconType } from "react-icons"
import { TbCheck, TbCopy, TbDeviceDesktopShare, TbDeviceMobileShare } from "react-icons/tb"
const useStyles = tss import { tss } from "tss"
.withParams<{
color: Color type Color = `#${string}`
}>()
.create(({ theme, colorScheme, color }) => ({ const useStyles = tss
socialIcon: { .withParams<{
color, color: Color
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white", }>()
borderRadius: "50%", .create(({ theme, colorScheme, color }) => ({
}, icon: {
})) color,
backgroundColor: colorScheme === "dark" ? theme.colors.gray[2] : "white",
function ShareButton({ url, icon, color }: { url: string; icon: IconType; color: Color }) { },
const { classes } = useStyles({ }))
color,
}) function ShareButton({ icon, color, onClick }: { icon: IconType; color: Color; onClick: () => void }) {
const { classes } = useStyles({
const onClick = (e: React.MouseEvent) => { color,
e.preventDefault() })
window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
} return (
<ActionIcon variant="transparent" radius="xl" size={32}>
return ( <Box p={6} className={classes.icon} onClick={onClick}>
<ActionIcon variant="transparent"> {icon({ size: 18 })}
<a href={url} target="_blank" rel="noreferrer" onClick={onClick}> </Box>
<Box p={6} className={classes.socialIcon}> </ActionIcon>
{icon({ size: 18 })} )
</Box> }
</a>
</ActionIcon> function SiteShareButton({ url, icon, color }: { icon: IconType; color: Color; url: string }) {
) const onClick = () => {
} window.open(url, "", "menubar=no,toolbar=no,resizable=yes,scrollbars=yes,width=800,height=600")
}
export function ShareButtons(props: { url: string; description: string }) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) return <ShareButton icon={icon} color={color} onClick={onClick} />
const url = encodeURIComponent(props.url) }
const desc = encodeURIComponent(props.description)
function CopyUrlButton({ url }: { url: string }) {
return ( return (
<SimpleGrid cols={4}> <CopyButton value={url}>
{(Object.keys(Constants.sharing) as (keyof SharingSettings)[]) {({ copied, copy }) => <ShareButton icon={copied ? TbCheck : TbCopy} color="#000" onClick={copy} />}
.filter(site => sharingSettings?.[site]) </CopyButton>
.map(site => ( )
<ShareButton }
key={site}
icon={Constants.sharing[site].icon} function BrowserNativeShareButton({ url, description }: { url: string; description: string }) {
color={Constants.sharing[site].color} const mobile = useMobile()
url={Constants.sharing[site].url(url, desc)} const { isBrowserExtensionPopup } = useBrowserExtension()
/> const onClick = () => {
))} navigator.share({
</SimpleGrid> title: description,
) url,
} })
}
return (
<ShareButton
icon={mobile && !isBrowserExtensionPopup ? TbDeviceMobileShare : TbDeviceDesktopShare}
color="#000"
onClick={onClick}
/>
)
}
export function ShareButtons(props: { url: string; description: string }) {
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
const enabledSharingSites = (Object.keys(Constants.sharing) as Array<keyof SharingSettings>).filter(site => sharingSettings?.[site])
const url = encodeURIComponent(props.url)
const desc = encodeURIComponent(props.description)
const clipboardAvailable = typeof navigator.clipboard !== "undefined"
const nativeSharingAvailable = typeof navigator.share !== "undefined"
const showNativeSection = clipboardAvailable || nativeSharingAvailable
const showSharingSites = enabledSharingSites.length > 0
const showDivider = showNativeSection && showSharingSites
const showNoSharingOptionsAvailable = !showNativeSection && !showSharingSites
return (
<>
{showNativeSection && (
<SimpleGrid cols={4}>
{clipboardAvailable && <CopyUrlButton url={props.url} />}
{nativeSharingAvailable && <BrowserNativeShareButton url={props.url} description={props.description} />}
</SimpleGrid>
)}
{showDivider && <Divider my="xs" />}
{showSharingSites && (
<SimpleGrid cols={4}>
{enabledSharingSites.map(site => (
<SiteShareButton
key={site}
icon={Constants.sharing[site].icon}
color={Constants.sharing[site].color}
url={Constants.sharing[site].url(url, desc)}
/>
))}
</SimpleGrid>
)}
{showNoSharingOptionsAvailable && <Trans>No sharing options available.</Trans>}
</>
)
}

View File

@@ -1,50 +1,52 @@
import { t, Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Box, Button, Group, Stack, TextInput } from "@mantine/core" import { useLingui } from "@lingui/react"
import { useForm } from "@mantine/form" import { Box, Button, Group, Stack, TextInput } from "@mantine/core"
import { client, errorToStrings } from "app/client" import { useForm } from "@mantine/form"
import { redirectToSelectedSource } from "app/redirect/thunks" import { client, errorToStrings } from "app/client"
import { useAppDispatch } from "app/store" import { redirectToSelectedSource } from "app/redirect/thunks"
import { reloadTree } from "app/tree/thunks" import { useAppDispatch } from "app/store"
import { type AddCategoryRequest } from "app/types" import { reloadTree } from "app/tree/thunks"
import { Alert } from "components/Alert" import type { AddCategoryRequest } from "app/types"
import { useAsyncCallback } from "react-async-hook" import { Alert } from "components/Alert"
import { TbFolderPlus } from "react-icons/tb" import { useAsyncCallback } from "react-async-hook"
import { CategorySelect } from "./CategorySelect" import { TbFolderPlus } from "react-icons/tb"
import { CategorySelect } from "./CategorySelect"
export function AddCategory() {
const dispatch = useAppDispatch() export function AddCategory() {
const dispatch = useAppDispatch()
const form = useForm<AddCategoryRequest>() const { _ } = useLingui()
const addCategory = useAsyncCallback(client.category.add, { const form = useForm<AddCategoryRequest>()
onSuccess: () => {
dispatch(reloadTree()) const addCategory = useAsyncCallback(client.category.add, {
dispatch(redirectToSelectedSource()) onSuccess: () => {
}, dispatch(reloadTree())
}) dispatch(redirectToSelectedSource())
},
return ( })
<>
{addCategory.error && ( return (
<Box mb="md"> <>
<Alert messages={errorToStrings(addCategory.error)} /> {addCategory.error && (
</Box> <Box mb="md">
)} <Alert messages={errorToStrings(addCategory.error)} />
</Box>
<form onSubmit={form.onSubmit(addCategory.execute)}> )}
<Stack>
<TextInput label={<Trans>Category</Trans>} placeholder={t`Category`} {...form.getInputProps("name")} required /> <form onSubmit={form.onSubmit(addCategory.execute)}>
<CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable /> <Stack>
<Group justify="center"> <TextInput label={<Trans>Category</Trans>} placeholder={_(msg`Category`)} {...form.getInputProps("name")} required />
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <CategorySelect label={<Trans>Parent</Trans>} {...form.getInputProps("parentId")} clearable />
<Trans>Cancel</Trans> <Group justify="center">
</Button> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}> <Trans>Cancel</Trans>
<Trans>Add</Trans> </Button>
</Button> <Button type="submit" leftSection={<TbFolderPlus size={16} />} loading={addCategory.loading}>
</Group> <Trans>Add</Trans>
</Stack> </Button>
</form> </Group>
</> </Stack>
) </form>
} </>
)
}

View File

@@ -1,51 +1,55 @@
import { t } from "@lingui/macro" import { msg } from "@lingui/macro"
import { Select, type SelectProps } from "@mantine/core" import { useLingui } from "@lingui/react"
import { type ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types" import { Select, type SelectProps } from "@mantine/core"
import { Constants } from "app/constants" import type { ComboboxItem } from "@mantine/core/lib/components/Combobox/Combobox.types"
import { useAppSelector } from "app/store" import { Constants } from "app/constants"
import { type Category } from "app/types" import { useAppSelector } from "app/store"
import { flattenCategoryTree } from "app/utils" import type { Category } from "app/types"
import { flattenCategoryTree } from "app/utils"
type CategorySelectProps = Partial<SelectProps> & {
withAll?: boolean type CategorySelectProps = Partial<SelectProps> & {
withoutCategoryIds?: string[] withAll?: boolean
} withoutCategoryIds?: string[]
}
export function CategorySelect(props: CategorySelectProps) {
const rootCategory = useAppSelector(state => state.tree.rootCategory) export function CategorySelect(props: CategorySelectProps) {
const categories = rootCategory && flattenCategoryTree(rootCategory) const rootCategory = useAppSelector(state => state.tree.rootCategory)
const categoriesById = categories?.reduce((map, c) => { const { _ } = useLingui()
map.set(c.id, c)
return map const categories = rootCategory && flattenCategoryTree(rootCategory)
}, new Map<string, Category>()) const categoriesById = categories?.reduce((map, c) => {
const categoryLabel = (cat: Category) => { map.set(c.id, c)
let label = cat.name return map
}, new Map<string, Category>())
while (cat.parentId) { const categoryLabel = (category: Category) => {
const parent = categoriesById?.get(cat.parentId) let cat = category
if (!parent) { let label = cat.name
break
} while (cat.parentId) {
label = `${parent.name}${label}` const parent = categoriesById?.get(cat.parentId)
cat = parent if (!parent) {
} break
}
return label label = `${parent.name}${label}`
} cat = parent
const selectData: ComboboxItem[] | undefined = categories }
?.filter(c => c.id !== Constants.categories.all.id)
.filter(c => !props.withoutCategoryIds?.includes(c.id)) return label
.map(c => ({ }
label: categoryLabel(c), const selectData: ComboboxItem[] | undefined = categories
value: c.id, ?.filter(c => c.id !== Constants.categories.all.id)
})) .filter(c => !props.withoutCategoryIds?.includes(c.id))
.sort((c1, c2) => c1.label.localeCompare(c2.label)) .map(c => ({
if (props.withAll) { label: categoryLabel(c),
selectData?.unshift({ value: c.id,
label: t`All`, }))
value: Constants.categories.all.id, .sort((c1, c2) => c1.label.localeCompare(c2.label))
}) if (props.withAll) {
} selectData?.unshift({
label: _(msg`All`),
return <Select {...props} data={selectData ?? []} disabled={!selectData} /> value: Constants.categories.all.id,
} })
}
return <Select {...props} data={selectData ?? []} disabled={!selectData} />
}

View File

@@ -1,65 +1,66 @@
import { t, Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Box, Button, FileInput, Group, Stack } from "@mantine/core" import { useLingui } from "@lingui/react"
import { useForm } from "@mantine/form" import { Box, Button, FileInput, Group, Stack } from "@mantine/core"
import { client, errorToStrings } from "app/client" import { isNotEmpty, useForm } from "@mantine/form"
import { redirectToSelectedSource } from "app/redirect/thunks" import { client, errorToStrings } from "app/client"
import { useAppDispatch } from "app/store" import { redirectToSelectedSource } from "app/redirect/thunks"
import { reloadTree } from "app/tree/thunks" import { useAppDispatch } from "app/store"
import { Alert } from "components/Alert" import { reloadTree } from "app/tree/thunks"
import { useAsyncCallback } from "react-async-hook" import { Alert } from "components/Alert"
import { TbFileImport } from "react-icons/tb" import { useAsyncCallback } from "react-async-hook"
import { TbFileImport } from "react-icons/tb"
export function ImportOpml() {
const dispatch = useAppDispatch() export function ImportOpml() {
const dispatch = useAppDispatch()
const form = useForm<{ file: File }>({ const { _ } = useLingui()
validate: {
file: () => t`file is required`, const form = useForm<{ file: File }>({
}, validate: {
}) file: isNotEmpty(_(msg`OPML file is required`)),
},
const importOpml = useAsyncCallback(client.feed.importOpml, { })
onSuccess: () => {
dispatch(reloadTree()) const importOpml = useAsyncCallback(client.feed.importOpml, {
dispatch(redirectToSelectedSource()) onSuccess: () => {
}, dispatch(reloadTree())
}) dispatch(redirectToSelectedSource())
},
return ( })
<>
{importOpml.error && ( return (
<Box mb="md"> <>
<Alert messages={errorToStrings(importOpml.error)} /> {importOpml.error && (
</Box> <Box mb="md">
)} <Alert messages={errorToStrings(importOpml.error)} />
</Box>
<form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}> )}
<Stack>
<FileInput <form onSubmit={form.onSubmit(async v => await importOpml.execute(v.file))}>
label={<Trans>OPML file</Trans>} <Stack>
leftSection={<TbFileImport />} <FileInput
// https://github.com/mantinedev/mantine/issues/5401 label={<Trans>OPML file</Trans>}
{...{ placeholder: t`OPML file` }} leftSection={<TbFileImport />}
description={ placeholder={_(msg`OPML file`)}
<Trans> description={
An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your <Trans>
data from other feed reading services. An opml file is an XML file containing feed URLs and categories. You can get an OPML file by exporting your
</Trans> data from other feed reading services.
} </Trans>
{...form.getInputProps("file")} }
required {...form.getInputProps("file")}
accept="application/xml" required
/> accept=".xml,.opml"
<Group justify="center"> />
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}> <Group justify="center">
<Trans>Cancel</Trans> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
</Button> <Trans>Cancel</Trans>
<Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}> </Button>
<Trans>Import</Trans> <Button type="submit" leftSection={<TbFileImport size={16} />} loading={importOpml.loading}>
</Button> <Trans>Import</Trans>
</Group> </Button>
</Stack> </Group>
</form> </Stack>
</> </form>
) </>
} )
}

View File

@@ -1,129 +1,129 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core" import { Box, Button, Group, Stack, Stepper, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { client, errorToStrings } from "app/client"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks" import { redirectToFeed, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch } from "app/store" import { useAppDispatch } from "app/store"
import { reloadTree } from "app/tree/thunks" import { reloadTree } from "app/tree/thunks"
import { type FeedInfoRequest, type SubscribeRequest } from "app/types" import type { FeedInfoRequest, SubscribeRequest } from "app/types"
import { Alert } from "components/Alert" import { Alert } from "components/Alert"
import { useState } from "react" import { useState } from "react"
import { useAsyncCallback } from "react-async-hook" import { useAsyncCallback } from "react-async-hook"
import { TbRss } from "react-icons/tb" import { TbRss } from "react-icons/tb"
import { CategorySelect } from "./CategorySelect" import { CategorySelect } from "./CategorySelect"
export function Subscribe() { export function Subscribe() {
const [activeStep, setActiveStep] = useState(0) const [activeStep, setActiveStep] = useState(0)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const step0Form = useForm<FeedInfoRequest>({ const step0Form = useForm<FeedInfoRequest>({
initialValues: { initialValues: {
url: "", url: "",
}, },
}) })
const step1Form = useForm<SubscribeRequest>({ const step1Form = useForm<SubscribeRequest>({
initialValues: { initialValues: {
url: "", url: "",
title: "", title: "",
categoryId: Constants.categories.all.id, categoryId: Constants.categories.all.id,
}, },
}) })
const fetchFeed = useAsyncCallback(client.feed.fetchFeed, { const fetchFeed = useAsyncCallback(client.feed.fetchFeed, {
onSuccess: ({ data }) => { onSuccess: ({ data }) => {
step1Form.setFieldValue("url", data.url) step1Form.setFieldValue("url", data.url)
step1Form.setFieldValue("title", data.title) step1Form.setFieldValue("title", data.title)
setActiveStep(step => step + 1) setActiveStep(step => step + 1)
}, },
}) })
const subscribe = useAsyncCallback(client.feed.subscribe, { const subscribe = useAsyncCallback(client.feed.subscribe, {
onSuccess: sub => { onSuccess: sub => {
dispatch(reloadTree()) dispatch(reloadTree())
dispatch(redirectToFeed(sub.data)) dispatch(redirectToFeed(sub.data))
}, },
}) })
const previousStep = () => { const previousStep = () => {
if (activeStep === 0) { if (activeStep === 0) {
dispatch(redirectToSelectedSource()) dispatch(redirectToSelectedSource())
} else { } else {
setActiveStep(activeStep - 1) setActiveStep(activeStep - 1)
} }
} }
const nextStep = (e: React.FormEvent<HTMLFormElement>) => { const nextStep = (e: React.FormEvent<HTMLFormElement>) => {
if (activeStep === 0) { if (activeStep === 0) {
step0Form.onSubmit(fetchFeed.execute)(e) step0Form.onSubmit(fetchFeed.execute)(e)
} else if (activeStep === 1) { } else if (activeStep === 1) {
step1Form.onSubmit(subscribe.execute)(e) step1Form.onSubmit(subscribe.execute)(e)
} }
} }
return ( return (
<> <>
{fetchFeed.error && ( {fetchFeed.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(fetchFeed.error)} /> <Alert messages={errorToStrings(fetchFeed.error)} />
</Box> </Box>
)} )}
{subscribe.error && ( {subscribe.error && (
<Box mb="md"> <Box mb="md">
<Alert messages={errorToStrings(subscribe.error)} /> <Alert messages={errorToStrings(subscribe.error)} />
</Box> </Box>
)} )}
<form onSubmit={nextStep}> <form onSubmit={nextStep}>
<Stepper active={activeStep} onStepClick={setActiveStep}> <Stepper active={activeStep} onStepClick={setActiveStep}>
<Stepper.Step <Stepper.Step
label={<Trans>Analyze feed</Trans>} label={<Trans>Analyze feed</Trans>}
description={<Trans>Check that the feed is working</Trans>} description={<Trans>Check that the feed is working</Trans>}
allowStepSelect={activeStep === 1} allowStepSelect={activeStep === 1}
> >
<TextInput <TextInput
label={<Trans>Feed URL</Trans>} label={<Trans>Feed URL</Trans>}
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 directly and CommaFeed
will try to find the feed in the page. will try to find the feed in the page.
</Trans> </Trans>
} }
required required
autoFocus autoFocus
{...step0Form.getInputProps("url")} {...step0Form.getInputProps("url")}
/> />
</Stepper.Step> </Stepper.Step>
<Stepper.Step <Stepper.Step
label={<Trans>Subscribe</Trans>} label={<Trans>Subscribe</Trans>}
description={<Trans>Subscribe to the feed</Trans>} description={<Trans>Subscribe to the feed</Trans>}
allowStepSelect={false} allowStepSelect={false}
> >
<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 label={<Trans>Feed name</Trans>} {...step1Form.getInputProps("title")} required autoFocus />
<CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable /> <CategorySelect label={<Trans>Category</Trans>} {...step1Form.getInputProps("categoryId")} clearable />
</Stack> </Stack>
</Stepper.Step> </Stepper.Step>
</Stepper> </Stepper>
<Group justify="center" mt="xl"> <Group justify="center" mt="xl">
<Button variant="default" onClick={previousStep}> <Button variant="default" onClick={previousStep}>
<Trans>Back</Trans> <Trans>Back</Trans>
</Button> </Button>
{activeStep === 0 && ( {activeStep === 0 && (
<Button type="submit" loading={fetchFeed.loading}> <Button type="submit" loading={fetchFeed.loading}>
<Trans>Next</Trans> <Trans>Next</Trans>
</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>
)} )}
</Group> </Group>
</form> </form>
</> </>
) )
} }

View File

@@ -1,66 +1,72 @@
import { Box, Text } from "@mantine/core" import { Box, Text } from "@mantine/core"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { RelativeDate } from "components/RelativeDate" import { RelativeDate } from "components/RelativeDate"
import { OnDesktop } from "components/responsive/OnDesktop" import { FeedFavicon } from "components/content/FeedFavicon"
import { tss } from "tss" import { OpenExternalLink } from "components/content/header/OpenExternalLink"
import { FeedEntryTitle } from "./FeedEntryTitle" import { Star } from "components/content/header/Star"
import { FeedFavicon } from "./FeedFavicon" import { OnDesktop } from "components/responsive/OnDesktop"
import { tss } from "tss"
export interface FeedEntryHeaderProps { import { FeedEntryTitle } from "./FeedEntryTitle"
entry: Entry
} export interface FeedEntryHeaderProps {
entry: Entry
const useStyles = tss showStarIcon?: boolean
.withParams<{ showExternalLinkIcon?: boolean
read: boolean }
}>()
.create(({ colorScheme, read }) => ({ const useStyles = tss
wrapper: { .withParams<{
display: "flex", read: boolean
alignItems: "center", }>()
columnGap: "10px", .create(({ colorScheme, read }) => ({
}, wrapper: {
title: { display: "flex",
flexGrow: 1, alignItems: "center",
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit", columnGap: "10px",
whiteSpace: "nowrap", },
overflow: "hidden", title: {
textOverflow: "ellipsis", flexGrow: 1,
}, fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
feedName: { whiteSpace: "nowrap",
width: "145px", overflow: "hidden",
minWidth: "145px", textOverflow: "ellipsis",
whiteSpace: "nowrap", },
overflow: "hidden", feedName: {
textOverflow: "ellipsis", width: "145px",
}, minWidth: "145px",
date: { whiteSpace: "nowrap",
whiteSpace: "nowrap", overflow: "hidden",
}, textOverflow: "ellipsis",
})) },
date: {
export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) { whiteSpace: "nowrap",
const { classes } = useStyles({ },
read: props.entry.read, }))
})
return ( export function FeedEntryCompactHeader(props: FeedEntryHeaderProps) {
<Box className={classes.wrapper}> const { classes } = useStyles({
<Box> read: props.entry.read,
<FeedFavicon url={props.entry.iconUrl} /> })
</Box> return (
<OnDesktop> <Box className={classes.wrapper}>
<Text c="dimmed" className={classes.feedName}> {props.showStarIcon && <Star entry={props.entry} />}
{props.entry.feedName} <Box>
</Text> <FeedFavicon url={props.entry.iconUrl} />
</OnDesktop> </Box>
<Box className={classes.title}> <OnDesktop>
<FeedEntryTitle entry={props.entry} /> <Text c="dimmed" className={classes.feedName}>
</Box> {props.entry.feedName}
<OnDesktop> </Text>
<Text c="dimmed" className={classes.date}> </OnDesktop>
<RelativeDate date={props.entry.date} /> <Box className={classes.title}>
</Text> <FeedEntryTitle entry={props.entry} />
</OnDesktop> </Box>
</Box> <OnDesktop>
) <Text c="dimmed" className={classes.date}>
} <RelativeDate date={props.entry.date} />
</Text>
</OnDesktop>
{props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
</Box>
)
}

View File

@@ -1,57 +1,67 @@
import { Box, Space, Text } from "@mantine/core" import { Box, Flex, Space, Text } from "@mantine/core"
import { type Entry } from "app/types" import type { Entry } from "app/types"
import { RelativeDate } from "components/RelativeDate" import { RelativeDate } from "components/RelativeDate"
import { tss } from "tss" import { FeedFavicon } from "components/content/FeedFavicon"
import { FeedEntryTitle } from "./FeedEntryTitle" import { OpenExternalLink } from "components/content/header/OpenExternalLink"
import { FeedFavicon } from "./FeedFavicon" import { Star } from "components/content/header/Star"
import { tss } from "tss"
export interface FeedEntryHeaderProps { import { FeedEntryTitle } from "./FeedEntryTitle"
entry: Entry
expanded: boolean export interface FeedEntryHeaderProps {
} entry: Entry
expanded: boolean
const useStyles = tss showStarIcon?: boolean
.withParams<{ showExternalLinkIcon?: boolean
read: boolean }
}>()
.create(({ colorScheme, read }) => ({ const useStyles = tss
headerText: { .withParams<{
fontWeight: colorScheme === "light" && !read ? "bold" : "inherit", read: boolean
}, }>()
headerSubtext: { .create(({ colorScheme, read }) => ({
display: "flex", main: {
alignItems: "center", fontWeight: colorScheme === "light" && !read ? "bold" : "inherit",
fontSize: "90%", },
}, details: {
})) fontSize: "90%",
},
export function FeedEntryHeader(props: FeedEntryHeaderProps) { }))
const { classes } = useStyles({
read: props.entry.read, export function FeedEntryHeader(props: FeedEntryHeaderProps) {
}) const { classes } = useStyles({
return ( read: props.entry.read,
<Box> })
<Box className={classes.headerText}> return (
<FeedEntryTitle entry={props.entry} /> <Box>
</Box> <Flex align="flex-start" justify="space-between">
<Box className={classes.headerSubtext}> <Flex align="flex-start" className={classes.main}>
<FeedFavicon url={props.entry.iconUrl} /> {props.showStarIcon && (
<Space w={6} /> <Box ml={-5}>
<Text c="dimmed"> <Star entry={props.entry} />
{props.entry.feedName} </Box>
<span> · </span> )}
<RelativeDate date={props.entry.date} /> <FeedEntryTitle entry={props.entry} />
</Text> </Flex>
</Box> {props.showExternalLinkIcon && <OpenExternalLink entry={props.entry} />}
{props.expanded && ( </Flex>
<Box className={classes.headerSubtext}> <Flex align="center" className={classes.details}>
<Text c="dimmed"> <FeedFavicon url={props.entry.iconUrl} />
{props.entry.author && <span>by {props.entry.author}</span>} <Space w={6} />
{props.entry.author && props.entry.categories && <span>&nbsp;·&nbsp;</span>} <Text c="dimmed">
{props.entry.categories && <span>{props.entry.categories}</span>} {props.entry.feedName}
</Text> <span> · </span>
</Box> <RelativeDate date={props.entry.date} />
)} </Text>
</Box> </Flex>
) {props.expanded && (
} <Box className={classes.details}>
<Text c="dimmed">
{props.entry.author && <span>by {props.entry.author}</span>}
{props.entry.author && props.entry.categories && <span>&nbsp;·&nbsp;</span>}
{props.entry.categories && <span>{props.entry.categories}</span>}
</Text>
</Box>
)}
</Box>
)
}

View File

@@ -1,22 +1,22 @@
import { Highlight } from "@mantine/core" import { Highlight } from "@mantine/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import { type Entry } from "app/types" import type { Entry } from "app/types"
export interface FeedEntryTitleProps { export interface FeedEntryTitleProps {
entry: Entry entry: Entry
} }
export function FeedEntryTitle(props: FeedEntryTitleProps) { export function FeedEntryTitle(props: FeedEntryTitleProps) {
const search = useAppSelector(state => state.entries.search) const search = useAppSelector(state => state.entries.search)
const keywords = search?.split(" ") const keywords = search?.split(" ")
return ( return (
<Highlight <Highlight
inherit inherit
highlight={keywords ?? ""} highlight={keywords ?? ""}
// make sure ellipsis is shown when title is too long // make sure ellipsis is shown when title is too long
span span
> >
{props.entry.title} {props.entry.title}
</Highlight> </Highlight>
) )
} }

View File

@@ -0,0 +1,30 @@
import { Trans } from "@lingui/macro"
import { ActionIcon, Anchor, Tooltip } from "@mantine/core"
import { Constants } from "app/constants"
import { markEntry } from "app/entries/thunks"
import { useAppDispatch } from "app/store"
import type { Entry } from "app/types"
import { TbExternalLink } from "react-icons/tb"
export function OpenExternalLink(props: { entry: Entry }) {
const dispatch = useAppDispatch()
const onClick = (e: React.MouseEvent) => {
e.stopPropagation()
dispatch(
markEntry({
entry: props.entry,
read: true,
})
)
}
return (
<Anchor href={props.entry.url} target="_blank" rel="noreferrer" onClick={onClick}>
<Tooltip label={<Trans>Open link</Trans>} openDelay={Constants.tooltip.delay}>
<ActionIcon variant="transparent" c="dimmed">
<TbExternalLink size={18} />
</ActionIcon>
</Tooltip>
</Anchor>
)
}

View File

@@ -0,0 +1,29 @@
import { Trans } from "@lingui/macro"
import { ActionIcon, Tooltip } from "@mantine/core"
import { Constants } from "app/constants"
import { starEntry } from "app/entries/thunks"
import { useAppDispatch } from "app/store"
import type { Entry } from "app/types"
import { TbStar, TbStarFilled } from "react-icons/tb"
export function Star(props: { entry: Entry }) {
const dispatch = useAppDispatch()
const onClick = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
dispatch(
starEntry({
entry: props.entry,
starred: !props.entry.starred,
})
)
}
return (
<Tooltip label={props.entry.starred ? <Trans>Unstar</Trans> : <Trans>Star</Trans>} openDelay={Constants.tooltip.delay}>
<ActionIcon variant="transparent" onClick={onClick}>
{props.entry.starred ? <TbStarFilled size={18} /> : <TbStar size={18} />}
</ActionIcon>
</Tooltip>
)
}

View File

@@ -1,169 +1,171 @@
import { t, Trans } from "@lingui/macro" import { msg } from "@lingui/macro"
import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core" import { useLingui } from "@lingui/react"
import { useForm } from "@mantine/form" import { Box, Center, CloseButton, Divider, Group, Indicator, Popover, TextInput } from "@mantine/core"
import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks" import { useForm } from "@mantine/form"
import { useAppDispatch, useAppSelector } from "app/store" import { reloadEntries, search, selectNextEntry, selectPreviousEntry } from "app/entries/thunks"
import { changeReadingMode, changeReadingOrder } from "app/user/thunks" import { useAppDispatch, useAppSelector } from "app/store"
import { ActionButton } from "components/ActionButton" import { changeReadingMode, changeReadingOrder } from "app/user/thunks"
import { Loader } from "components/Loader" import { ActionButton } from "components/ActionButton"
import { useActionButton } from "hooks/useActionButton" import { Loader } from "components/Loader"
import { useBrowserExtension } from "hooks/useBrowserExtension" import { useActionButton } from "hooks/useActionButton"
import { useMobile } from "hooks/useMobile" import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useEffect } from "react" import { useMobile } from "hooks/useMobile"
import { import { useEffect } from "react"
TbArrowDown, import {
TbArrowUp, TbArrowDown,
TbExternalLink, TbArrowUp,
TbEye, TbExternalLink,
TbEyeOff, TbEye,
TbRefresh, TbEyeOff,
TbSearch, TbRefresh,
TbSettings, TbSearch,
TbSortAscending, TbSettings,
TbSortDescending, TbSortAscending,
TbUser, TbSortDescending,
} from "react-icons/tb" TbUser,
import { MarkAllAsReadButton } from "./MarkAllAsReadButton" } from "react-icons/tb"
import { ProfileMenu } from "./ProfileMenu" import { MarkAllAsReadButton } from "./MarkAllAsReadButton"
import { ProfileMenu } from "./ProfileMenu"
function HeaderDivider() {
return <Divider orientation="vertical" /> function HeaderDivider() {
} return <Divider orientation="vertical" />
}
function HeaderToolbar(props: { children: React.ReactNode }) {
const { spacing } = useActionButton() function HeaderToolbar(props: { children: React.ReactNode }) {
const mobile = useMobile("480px") const { spacing } = useActionButton()
return mobile ? ( const mobile = useMobile("480px")
// on mobile use all available width return mobile ? (
<Box // on mobile use all available width
style={{ <Box
width: "100%", style={{
display: "flex", width: "100%",
justifyContent: "space-between", display: "flex",
}} justifyContent: "space-between",
> }}
{props.children} >
</Box> {props.children}
) : ( </Box>
<Group gap={spacing}>{props.children}</Group> ) : (
) <Group gap={spacing}>{props.children}</Group>
} )
}
const iconSize = 18
const iconSize = 18
export function Header() {
const settings = useAppSelector(state => state.user.settings) export function Header() {
const profile = useAppSelector(state => state.user.profile) const settings = useAppSelector(state => state.user.settings)
const searchFromStore = useAppSelector(state => state.entries.search) const profile = useAppSelector(state => state.user.profile)
const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension() const searchFromStore = useAppSelector(state => state.entries.search)
const dispatch = useAppDispatch() const { isBrowserExtensionPopup, openSettingsPage, openAppInNewTab } = useBrowserExtension()
const dispatch = useAppDispatch()
const searchForm = useForm<{ search: string }>({ const { _ } = useLingui()
validate: {
search: value => (value.length > 0 && value.length < 3 ? t`Search requires at least 3 characters` : null), const searchForm = useForm<{ search: string }>({
}, validate: {
}) search: value => (value.length > 0 && value.length < 3 ? _(msg`Search requires at least 3 characters`) : null),
const { setValues } = searchForm },
})
useEffect(() => { const { setValues } = searchForm
setValues({
search: searchFromStore, useEffect(() => {
}) setValues({
}, [setValues, searchFromStore]) search: searchFromStore,
})
if (!settings) return <Loader /> }, [setValues, searchFromStore])
return (
<Center> if (!settings) return <Loader />
<HeaderToolbar> return (
<ActionButton <Center>
icon={<TbArrowUp size={iconSize} />} <HeaderToolbar>
label={<Trans>Previous</Trans>} <ActionButton
onClick={async () => icon={<TbArrowUp size={iconSize} />}
await dispatch( label={msg`Previous`}
selectPreviousEntry({ onClick={async () =>
expand: true, await dispatch(
markAsRead: true, selectPreviousEntry({
scrollToEntry: true, expand: true,
}) markAsRead: true,
) scrollToEntry: true,
} })
/> )
<ActionButton }
icon={<TbArrowDown size={iconSize} />} />
label={<Trans>Next</Trans>} <ActionButton
onClick={async () => icon={<TbArrowDown size={iconSize} />}
await dispatch( label={msg`Next`}
selectNextEntry({ onClick={async () =>
expand: true, await dispatch(
markAsRead: true, selectNextEntry({
scrollToEntry: true, expand: true,
}) markAsRead: true,
) scrollToEntry: true,
} })
/> )
}
<HeaderDivider /> />
<ActionButton <HeaderDivider />
icon={<TbRefresh size={iconSize} />}
label={<Trans>Refresh</Trans>} <ActionButton
onClick={async () => await dispatch(reloadEntries())} icon={<TbRefresh size={iconSize} />}
/> label={msg`Refresh`}
<MarkAllAsReadButton iconSize={iconSize} /> onClick={async () => await dispatch(reloadEntries())}
/>
<HeaderDivider /> <MarkAllAsReadButton iconSize={iconSize} />
<ActionButton <HeaderDivider />
icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
label={settings.readingMode === "all" ? <Trans>All</Trans> : <Trans>Unread</Trans>} <ActionButton
onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))} icon={settings.readingMode === "all" ? <TbEye size={iconSize} /> : <TbEyeOff size={iconSize} />}
/> label={settings.readingMode === "all" ? msg`All` : msg`Unread`}
<ActionButton onClick={async () => await dispatch(changeReadingMode(settings.readingMode === "all" ? "unread" : "all"))}
icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />} />
label={settings.readingOrder === "asc" ? <Trans>Asc</Trans> : <Trans>Desc</Trans>} <ActionButton
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))} icon={settings.readingOrder === "asc" ? <TbSortAscending size={iconSize} /> : <TbSortDescending size={iconSize} />}
/> label={settings.readingOrder === "asc" ? msg`Asc` : msg`Desc`}
onClick={async () => await dispatch(changeReadingOrder(settings.readingOrder === "asc" ? "desc" : "asc"))}
<Popover> />
<Popover.Target>
<Indicator disabled={!searchFromStore}> <Popover>
<ActionButton icon={<TbSearch size={iconSize} />} label={<Trans>Search</Trans>} /> <Popover.Target>
</Indicator> <Indicator disabled={!searchFromStore}>
</Popover.Target> <ActionButton icon={<TbSearch size={iconSize} />} label={msg`Search`} />
<Popover.Dropdown> </Indicator>
<form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}> </Popover.Target>
<TextInput <Popover.Dropdown>
placeholder={t`Search`} <form onSubmit={searchForm.onSubmit(async values => await dispatch(search(values.search)))}>
{...searchForm.getInputProps("search")} <TextInput
leftSection={<TbSearch size={iconSize} />} placeholder={_(msg`Search`)}
rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />} {...searchForm.getInputProps("search")}
autoFocus leftSection={<TbSearch size={iconSize} />}
/> rightSection={<CloseButton onClick={async () => await (searchFromStore && dispatch(search("")))} />}
</form> autoFocus
</Popover.Dropdown> />
</Popover> </form>
</Popover.Dropdown>
<HeaderDivider /> </Popover>
<ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} /> <HeaderDivider />
{isBrowserExtensionPopup && ( <ProfileMenu control={<ActionButton icon={<TbUser size={iconSize} />} label={profile?.name} />} />
<>
<HeaderDivider /> {isBrowserExtensionPopup && (
<>
<ActionButton <HeaderDivider />
icon={<TbSettings size={iconSize} />}
label={<Trans>Extension options</Trans>} <ActionButton
onClick={() => openSettingsPage()} icon={<TbSettings size={iconSize} />}
/> label={msg`Extension options`}
<ActionButton onClick={() => openSettingsPage()}
icon={<TbExternalLink size={iconSize} />} />
label={<Trans>Open CommaFeed</Trans>} <ActionButton
onClick={() => openAppInNewTab()} icon={<TbExternalLink size={iconSize} />}
/> label={msg`Open CommaFeed`}
</> onClick={() => openAppInNewTab()}
)} />
</HeaderToolbar> </>
</Center> )}
) </HeaderToolbar>
} </Center>
)
}

View File

@@ -1,97 +1,97 @@
import { Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core" import { Button, Code, Group, Modal, Slider, Stack, Text } from "@mantine/core"
import { markAllEntries } from "app/entries/thunks" import { markAllEntries } from "app/entries/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { ActionButton } from "components/ActionButton" import { ActionButton } from "components/ActionButton"
import { useState } from "react" import { useState } from "react"
import { TbChecks } from "react-icons/tb" import { TbChecks } from "react-icons/tb"
export function MarkAllAsReadButton(props: { iconSize: number }) { export function MarkAllAsReadButton(props: { iconSize: number }) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const [threshold, setThreshold] = useState(0) const [threshold, setThreshold] = useState(0)
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 markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation) const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const buttonClicked = () => { const buttonClicked = () => {
if (markAllAsReadConfirmation) { if (markAllAsReadConfirmation) {
setThreshold(0) setThreshold(0)
setOpened(true) setOpened(true)
} else { } else {
dispatch( dispatch(
markAllEntries({ markAllEntries({
sourceType: source.type, sourceType: source.type,
req: { req: {
id: source.id, id: source.id,
read: true, read: true,
olderThan: Date.now(), olderThan: Date.now(),
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
}, },
}) })
) )
} }
} }
return ( return (
<> <>
<Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}> <Modal opened={opened} onClose={() => setOpened(false)} title={<Trans>Mark all entries as read</Trans>}>
<Stack> <Stack>
<Text size="sm"> <Text size="sm">
{threshold === 0 && ( {threshold === 0 && (
<Trans> <Trans>
Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read? Are you sure you want to mark all entries of <Code>{sourceLabel}</Code> as read?
</Trans> </Trans>
)} )}
{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>
<Slider <Slider
py="xl" py="xl"
min={0} min={0}
max={28} max={28}
marks={[ marks={[
{ value: 0, label: "0" }, { value: 0, label: "0" },
{ value: 7, label: "7" }, { value: 7, label: "7" },
{ value: 14, label: "14" }, { value: 14, label: "14" },
{ value: 21, label: "21" }, { value: 21, label: "21" },
{ value: 28, label: "28" }, { value: 28, label: "28" },
]} ]}
value={threshold} value={threshold}
onChange={setThreshold} onChange={setThreshold}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)}> <Button variant="default" onClick={() => setOpened(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setOpened(false) setOpened(false)
dispatch( dispatch(
markAllEntries({ markAllEntries({
sourceType: source.type, sourceType: source.type,
req: { req: {
id: source.id, id: source.id,
read: true, read: true,
olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000, olderThan: Date.now() - threshold * 24 * 60 * 60 * 1000,
insertedBefore: entriesTimestamp, insertedBefore: entriesTimestamp,
}, },
}) })
) )
}} }}
> >
<Trans>Confirm</Trans> <Trans>Confirm</Trans>
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<ActionButton icon={<TbChecks size={props.iconSize} />} label={<Trans>Mark all as read</Trans>} onClick={buttonClicked} /> <ActionButton icon={<TbChecks size={props.iconSize} />} label={msg`Mark all as read`} onClick={buttonClicked} />
</> </>
) )
} }

View File

@@ -1,217 +1,241 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { import {
Box, Box,
Divider, Divider,
Group, Group,
type MantineColorScheme, type MantineColorScheme,
Menu, Menu,
SegmentedControl, SegmentedControl,
type SegmentedControlItem, type SegmentedControlItem,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core" } from "@mantine/core"
import { showNotification } from "@mantine/notifications" import { showNotification } from "@mantine/notifications"
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 { useViewMode } from "hooks/useViewMode" import { setViewMode } from "app/user/slice"
import { type ReactNode, useState } from "react" import { reloadProfile } from "app/user/thunks"
import { import dayjs from "dayjs"
TbChartLine, import { useNow } from "hooks/useNow"
TbHeartFilled, import { type ReactNode, useState } from "react"
TbHelp, import {
TbLayoutList, TbChartLine,
TbList, TbHeartFilled,
TbListDetails, TbHelp,
TbMoon, TbLayoutList,
TbNotes, TbList,
TbPower, TbListDetails,
TbSettings, TbMoon,
TbSun, TbNotes,
TbSunMoon, TbPower,
TbUsers, TbSettings,
TbWorldDownload, TbSun,
} from "react-icons/tb" TbSunMoon,
TbUsers,
interface ProfileMenuProps { TbWorldDownload,
control: React.ReactElement } from "react-icons/tb"
}
interface ProfileMenuProps {
const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => { control: React.ReactElement
return ( }
<Group>
{icon} const ProfileMenuControlItem = ({ icon, label }: { icon: ReactNode; label: ReactNode }) => {
<Box ml={6}>{label}</Box> return (
</Group> <Group>
) {icon}
} <Box ml={6}>{label}</Box>
</Group>
const iconSize = 16 )
}
interface ColorSchemeControlItem extends SegmentedControlItem {
value: MantineColorScheme const iconSize = 16
}
interface ColorSchemeControlItem extends SegmentedControlItem {
const colorSchemeData: ColorSchemeControlItem[] = [ value: MantineColorScheme
{ }
value: "light",
label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />, const colorSchemeData: ColorSchemeControlItem[] = [
}, {
{ value: "light",
value: "dark", label: <ProfileMenuControlItem icon={<TbSun size={iconSize} />} label={<Trans>Light</Trans>} />,
label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />, },
}, {
{ value: "dark",
value: "auto", label: <ProfileMenuControlItem icon={<TbMoon size={iconSize} />} label={<Trans>Dark</Trans>} />,
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />, },
}, {
] value: "auto",
label: <ProfileMenuControlItem icon={<TbSunMoon size={iconSize} />} label={<Trans>System</Trans>} />,
interface ViewModeControlItem extends SegmentedControlItem { },
value: ViewMode ]
}
interface ViewModeControlItem extends SegmentedControlItem {
const viewModeData: ViewModeControlItem[] = [ value: ViewMode
{ }
value: "title",
label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />, const viewModeData: ViewModeControlItem[] = [
}, {
{ value: "title",
value: "cozy", label: <ProfileMenuControlItem icon={<TbList size={iconSize} />} label={<Trans>Compact</Trans>} />,
label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />, },
}, {
{ value: "cozy",
value: "detailed", label: <ProfileMenuControlItem icon={<TbLayoutList size={iconSize} />} label={<Trans>Cozy</Trans>} />,
label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />, },
}, {
{ value: "detailed",
value: "expanded", label: <ProfileMenuControlItem icon={<TbListDetails size={iconSize} />} label={<Trans>Detailed</Trans>} />,
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />, },
}, {
] value: "expanded",
label: <ProfileMenuControlItem icon={<TbNotes size={iconSize} />} label={<Trans>Expanded</Trans>} />,
export function ProfileMenu(props: ProfileMenuProps) { },
const [opened, setOpened] = useState(false) ]
const { viewMode, setViewMode } = useViewMode()
const profile = useAppSelector(state => state.user.profile) export function ProfileMenu(props: ProfileMenuProps) {
const admin = useAppSelector(state => state.user.profile?.admin) const [opened, setOpened] = useState(false)
const dispatch = useAppDispatch() const now = useNow()
const { colorScheme, setColorScheme } = useMantineColorScheme() const profile = useAppSelector(state => state.user.profile)
const admin = useAppSelector(state => state.user.profile?.admin)
const logout = () => { const viewMode = useAppSelector(state => state.user.localSettings.viewMode)
window.location.href = "logout" const forceRefreshCooldownDuration = useAppSelector(state => state.server.serverInfos?.forceRefreshCooldownDuration)
} const dispatch = useAppDispatch()
const { colorScheme, setColorScheme } = useMantineColorScheme()
return (
<Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}> const nextAvailableForceRefresh = profile?.lastForceRefresh
<Menu.Target>{props.control}</Menu.Target> ? profile.lastForceRefresh + (forceRefreshCooldownDuration ?? 0)
<Menu.Dropdown> : now.getTime()
{profile && <Menu.Label>{profile.name}</Menu.Label>} const forceRefreshEnabled = nextAvailableForceRefresh <= now.getTime()
<Menu.Item
leftSection={<TbSettings size={iconSize} />} const logout = () => {
onClick={() => { window.location.href = "logout"
dispatch(redirectToSettings()) }
setOpened(false)
}} return (
> <Menu position="bottom-end" closeOnItemClick={false} opened={opened} onChange={setOpened}>
<Trans>Settings</Trans> <Menu.Target>{props.control}</Menu.Target>
</Menu.Item> <Menu.Dropdown>
<Menu.Item {profile && <Menu.Label>{profile.name}</Menu.Label>}
leftSection={<TbWorldDownload size={iconSize} />} <Menu.Item
onClick={async () => leftSection={<TbSettings size={iconSize} />}
await client.feed.refreshAll().then(() => { onClick={() => {
showNotification({ dispatch(redirectToSettings())
message: <Trans>Your feeds have been queued for refresh.</Trans>, setOpened(false)
color: "green", }}
autoClose: 1000, >
}) <Trans>Settings</Trans>
setOpened(false) </Menu.Item>
}) <Menu.Item
} leftSection={<TbWorldDownload size={iconSize} />}
> disabled={!forceRefreshEnabled}
<Trans>Fetch all my feeds now</Trans> onClick={async () => {
</Menu.Item> setOpened(false)
<Divider /> try {
await client.feed.refreshAll()
<Menu.Label>
<Trans>Theme</Trans> // reload profile to update last force refresh timestamp
</Menu.Label> await dispatch(reloadProfile())
<SegmentedControl
fullWidth showNotification({
orientation="vertical" message: <Trans>Your feeds have been queued for refresh.</Trans>,
data={colorSchemeData} color: "green",
value={colorScheme} autoClose: 1000,
onChange={e => setColorScheme(e as MantineColorScheme)} })
mb="xs" } catch (error) {
/> showNotification({
message: <Trans>Force fetching feeds is not yet available.</Trans>,
<Divider /> color: "red",
autoClose: 2000,
<Menu.Label> })
<Trans>Display</Trans> }
</Menu.Label> }}
<SegmentedControl >
fullWidth <Trans>Fetch all my feeds now</Trans>
orientation="vertical" {!forceRefreshEnabled && <span> ({dayjs.duration(nextAvailableForceRefresh - now.getTime()).format("HH:mm:ss")})</span>}
data={viewModeData} </Menu.Item>
value={viewMode}
onChange={e => setViewMode(e as ViewMode)} <Divider />
mb="xs"
/> <Menu.Label>
<Trans>Theme</Trans>
{admin && ( </Menu.Label>
<> <SegmentedControl
<Divider /> fullWidth
<Menu.Label> orientation="vertical"
<Trans>Admin</Trans> data={colorSchemeData}
</Menu.Label> value={colorScheme}
<Menu.Item onChange={e => setColorScheme(e as MantineColorScheme)}
leftSection={<TbUsers size={iconSize} />} mb="xs"
onClick={() => { />
dispatch(redirectToAdminUsers())
setOpened(false) <Divider />
}}
> <Menu.Label>
<Trans>Manage users</Trans> <Trans>Display</Trans>
</Menu.Item> </Menu.Label>
<Menu.Item <SegmentedControl
leftSection={<TbChartLine size={iconSize} />} fullWidth
onClick={() => { orientation="vertical"
dispatch(redirectToMetrics()) data={viewModeData}
setOpened(false) value={viewMode}
}} onChange={e => dispatch(setViewMode(e as ViewMode))}
> mb="xs"
<Trans>Metrics</Trans> />
</Menu.Item>
</> {admin && (
)} <>
<Divider />
<Divider /> <Menu.Label>
<Trans>Admin</Trans>
<Menu.Item </Menu.Label>
leftSection={<TbHeartFilled size={iconSize} color="red" />} <Menu.Item
onClick={() => { leftSection={<TbUsers size={iconSize} />}
dispatch(redirectToDonate()) onClick={() => {
setOpened(false) dispatch(redirectToAdminUsers())
}} setOpened(false)
> }}
<Trans>Donate</Trans> >
</Menu.Item> <Trans>Manage users</Trans>
</Menu.Item>
<Menu.Item <Menu.Item
leftSection={<TbHelp size={iconSize} />} leftSection={<TbChartLine size={iconSize} />}
onClick={() => { onClick={() => {
dispatch(redirectToAbout()) dispatch(redirectToMetrics())
setOpened(false) setOpened(false)
}} }}
> >
<Trans>About</Trans> <Trans>Metrics</Trans>
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}> </>
<Trans>Logout</Trans> )}
</Menu.Item>
</Menu.Dropdown> <Divider />
</Menu>
) <Menu.Item
} leftSection={<TbHeartFilled size={iconSize} color="red" />}
onClick={() => {
dispatch(redirectToDonate())
setOpened(false)
}}
>
<Trans>Donate</Trans>
</Menu.Item>
<Menu.Item
leftSection={<TbHelp size={iconSize} />}
onClick={() => {
dispatch(redirectToAbout())
setOpened(false)
}}
>
<Trans>About</Trans>
</Menu.Item>
<Menu.Item leftSection={<TbPower size={iconSize} />} onClick={logout}>
<Trans>Logout</Trans>
</Menu.Item>
</Menu.Dropdown>
</Menu>
)
}

View File

@@ -1,9 +1,10 @@
import { type MetricGauge } from "app/types" import { NumberFormatter } from "@mantine/core"
import type { MetricGauge } from "app/types"
interface MeterProps {
gauge: MetricGauge interface MeterProps {
} gauge: MetricGauge
}
export function Gauge(props: MeterProps) {
return <span>{props.gauge.value}</span> export function Gauge(props: MeterProps) {
} return <NumberFormatter value={props.gauge.value} thousandSeparator />
}

View File

@@ -1,19 +1,19 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { type MetricMeter } from "app/types" import type { MetricMeter } from "app/types"
interface MeterProps { interface MeterProps {
meter: MetricMeter meter: MetricMeter
} }
export function Meter(props: MeterProps) { export function Meter(props: MeterProps) {
return ( return (
<Box> <Box>
<Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box> <Box>Mean: {props.meter.mean_rate.toFixed(2)}</Box>
<Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box> <Box>Last minute: {props.meter.m1_rate.toFixed(2)}</Box>
<Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box> <Box>Last 5 minutes: {props.meter.m5_rate.toFixed(2)}</Box>
<Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box> <Box>Last 15 minutes: {props.meter.m15_rate.toFixed(2)}</Box>
<Box>Units: {props.meter.units}</Box> <Box>Units: {props.meter.units}</Box>
<Box>Total: {props.meter.count}</Box> <Box>Total: {props.meter.count}</Box>
</Box> </Box>
) )
} }

View File

@@ -1,22 +1,22 @@
import { Accordion, Box, Group } from "@mantine/core" import { Accordion, Box, Group } from "@mantine/core"
interface MetricAccordionItemProps { interface MetricAccordionItemProps {
metricKey: string metricKey: string
name: string name: string
headerValue: number headerValue: number
children: React.ReactNode children: React.ReactNode
} }
export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) { export function MetricAccordionItem({ metricKey, name, headerValue, children }: MetricAccordionItemProps) {
return ( return (
<Accordion.Item value={metricKey} key={metricKey}> <Accordion.Item value={metricKey} key={metricKey}>
<Accordion.Control> <Accordion.Control>
<Group justify="space-between"> <Group justify="space-between">
<Box>{name}</Box> <Box>{name}</Box>
<Box>{headerValue}</Box> <Box>{headerValue}</Box>
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel>{children}</Accordion.Panel> <Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item> </Accordion.Item>
) )
} }

View File

@@ -1,19 +1,19 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { type MetricTimer } from "app/types" import type { MetricTimer } from "app/types"
interface MetricTimerProps { interface MetricTimerProps {
timer: MetricTimer timer: MetricTimer
} }
export function Timer(props: MetricTimerProps) { export function Timer(props: MetricTimerProps) {
return ( return (
<Box> <Box>
<Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box> <Box>Mean: {props.timer.mean_rate.toFixed(2)}</Box>
<Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box> <Box>Last minute: {props.timer.m1_rate.toFixed(2)}</Box>
<Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box> <Box>Last 5 minutes: {props.timer.m5_rate.toFixed(2)}</Box>
<Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box> <Box>Last 15 minutes: {props.timer.m15_rate.toFixed(2)}</Box>
<Box>Units: {props.timer.rate_units}</Box> <Box>Units: {props.timer.rate_units}</Box>
<Box>Total: {props.timer.count}</Box> <Box>Total: {props.timer.count}</Box>
</Box> </Box>
) )
} }

View File

@@ -1,8 +1,8 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import React from "react" import type React from "react"
export function OnDesktop(props: { children: React.ReactNode }) { export function OnDesktop(props: { children: React.ReactNode }) {
const mobile = useMobile() const mobile = useMobile()
return <Box>{!mobile && props.children}</Box> return <Box>{!mobile && props.children}</Box>
} }

View File

@@ -1,8 +1,8 @@
import { Box } from "@mantine/core" import { Box } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
import React from "react" import type React from "react"
export function OnMobile(props: { children: React.ReactNode }) { export function OnMobile(props: { children: React.ReactNode }) {
const mobile = useMobile() const mobile = useMobile()
return <Box>{mobile && props.children}</Box> return <Box>{mobile && props.children}</Box>
} }

View File

@@ -1,121 +1,191 @@
import { Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Divider, Group, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core" import { useLingui } from "@lingui/react"
import { Constants } from "app/constants" import { Divider, Group, NumberInput, Radio, Select, SimpleGrid, Stack, Switch } from "@mantine/core"
import { useAppDispatch, useAppSelector } from "app/store" import type { ComboboxData } from "@mantine/core/lib/components/Combobox/Combobox.types"
import { type ScrollMode, type SharingSettings } from "app/types" import { Constants } from "app/constants"
import { import { useAppDispatch, useAppSelector } from "app/store"
changeCustomContextMenu, import type { IconDisplayMode, ScrollMode, SharingSettings } from "app/types"
changeLanguage, import {
changeMarkAllAsReadConfirmation, changeCustomContextMenu,
changeMobileFooter, changeEntriesToKeepOnTopWhenScrolling,
changeScrollMarks, changeExternalLinkIconDisplayMode,
changeScrollMode, changeLanguage,
changeScrollSpeed, changeMarkAllAsReadConfirmation,
changeSharingSetting, changeMobileFooter,
changeShowRead, changeScrollMarks,
} from "app/user/thunks" changeScrollMode,
import { locales } from "i18n" changeScrollSpeed,
import { type ReactNode } from "react" changeSharingSetting,
changeShowRead,
export function DisplaySettings() { changeStarIconDisplayMode,
const language = useAppSelector(state => state.user.settings?.language) changeUnreadCountFavicon,
const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed) changeUnreadCountTitle,
const showRead = useAppSelector(state => state.user.settings?.showRead) } from "app/user/thunks"
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks) import { locales } from "i18n"
const scrollMode = useAppSelector(state => state.user.settings?.scrollMode) import type { ReactNode } from "react"
const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu) export function DisplaySettings() {
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter) const language = useAppSelector(state => state.user.settings?.language)
const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings) const scrollSpeed = useAppSelector(state => state.user.settings?.scrollSpeed)
const dispatch = useAppDispatch() const showRead = useAppSelector(state => state.user.settings?.showRead)
const scrollMarks = useAppSelector(state => state.user.settings?.scrollMarks)
const scrollModeOptions: Record<ScrollMode, ReactNode> = { const scrollMode = useAppSelector(state => state.user.settings?.scrollMode)
always: <Trans>Always</Trans>, const entriesToKeepOnTop = useAppSelector(state => state.user.settings?.entriesToKeepOnTopWhenScrolling)
never: <Trans>Never</Trans>, const starIconDisplayMode = useAppSelector(state => state.user.settings?.starIconDisplayMode)
if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>, const externalLinkIconDisplayMode = useAppSelector(state => state.user.settings?.externalLinkIconDisplayMode)
} const markAllAsReadConfirmation = useAppSelector(state => state.user.settings?.markAllAsReadConfirmation)
const customContextMenu = useAppSelector(state => state.user.settings?.customContextMenu)
return ( const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
<Stack> const unreadCountTitle = useAppSelector(state => state.user.settings?.unreadCountTitle)
<Select const unreadCountFavicon = useAppSelector(state => state.user.settings?.unreadCountFavicon)
description={<Trans>Language</Trans>} const sharingSettings = useAppSelector(state => state.user.settings?.sharingSettings)
value={language} const dispatch = useAppDispatch()
data={locales.map(l => ({ const { _ } = useLingui()
value: l.key,
label: l.label, const scrollModeOptions: Record<ScrollMode, ReactNode> = {
}))} always: <Trans>Always</Trans>,
onChange={async s => await (s && dispatch(changeLanguage(s)))} never: <Trans>Never</Trans>,
/> if_needed: <Trans>If the entry doesn't entirely fit on the screen</Trans>,
}
<Switch
label={<Trans>Show feeds and categories with no unread entries</Trans>} const displayModeData: ComboboxData = [
checked={showRead} {
onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))} value: "always",
/> label: _(msg`Always`),
},
<Switch {
label={<Trans>Show confirmation when marking all entries as read</Trans>} value: "on_desktop",
checked={markAllAsReadConfirmation} label: _(msg`On desktop`),
onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))} },
/> {
value: "on_mobile",
<Switch label: _(msg`On mobile`),
label={<Trans>Show CommaFeed's own context menu on right click</Trans>} },
checked={customContextMenu} {
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))} value: "never",
/> label: _(msg`Never`),
},
<Switch ]
label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
checked={mobileFooter} return (
onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))} <Stack>
/> <Select
description={<Trans>Language</Trans>}
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" /> value={language}
data={locales.map(l => ({
<Radio.Group value: l.key,
label={<Trans>Scroll selected entry to the top of the page</Trans>} label: l.label,
value={scrollMode} }))}
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))} onChange={async s => await (s && dispatch(changeLanguage(s)))}
> />
<Group mt="xs">
{Object.entries(scrollModeOptions).map(e => ( <Switch
<Radio key={e[0]} value={e[0]} label={e[1]} /> label={<Trans>Show feeds and categories with no unread entries</Trans>}
))} checked={showRead}
</Group> onChange={async e => await dispatch(changeShowRead(e.currentTarget.checked))}
</Radio.Group> />
<Switch <Switch
label={<Trans>Scroll smoothly when navigating between entries</Trans>} label={<Trans>Show confirmation when marking all entries as read</Trans>}
checked={scrollSpeed ? scrollSpeed > 0 : false} checked={markAllAsReadConfirmation}
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))} onChange={async e => await dispatch(changeMarkAllAsReadConfirmation(e.currentTarget.checked))}
/> />
<Switch <Switch
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>} label={<Trans>On mobile, show action buttons at the bottom of the screen</Trans>}
checked={scrollMarks} checked={mobileFooter}
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))} onChange={async e => await dispatch(changeMobileFooter(e.currentTarget.checked))}
/> />
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" /> <Divider label={<Trans>Browser tab</Trans>} labelPosition="center" />
<SimpleGrid cols={2}> <Switch
{(Object.keys(Constants.sharing) as (keyof SharingSettings)[]).map(site => ( label={<Trans>Show unread count in tab title</Trans>}
<Switch checked={unreadCountTitle}
key={site} onChange={async e => await dispatch(changeUnreadCountTitle(e.currentTarget.checked))}
label={Constants.sharing[site].label} />
checked={sharingSettings?.[site]}
onChange={async e => <Switch
await dispatch( label={<Trans>Show unread count in tab favicon</Trans>}
changeSharingSetting({ checked={unreadCountFavicon}
site, onChange={async e => await dispatch(changeUnreadCountFavicon(e.currentTarget.checked))}
value: e.currentTarget.checked, />
})
) <Divider label={<Trans>Entry headers</Trans>} labelPosition="center" />
}
/> <Select
))} description={<Trans>Show star icon</Trans>}
</SimpleGrid> value={starIconDisplayMode}
</Stack> data={displayModeData}
) onChange={async s => await dispatch(changeStarIconDisplayMode(s as IconDisplayMode))}
} />
<Select
description={<Trans>Show external link icon</Trans>}
value={externalLinkIconDisplayMode}
data={displayModeData}
onChange={async s => await dispatch(changeExternalLinkIconDisplayMode(s as IconDisplayMode))}
/>
<Switch
label={<Trans>Show CommaFeed's own context menu on right click</Trans>}
checked={customContextMenu}
onChange={async e => await dispatch(changeCustomContextMenu(e.currentTarget.checked))}
/>
<Divider label={<Trans>Scrolling</Trans>} labelPosition="center" />
<Radio.Group
label={<Trans>Scroll selected entry to the top of the page</Trans>}
value={scrollMode}
onChange={async value => await dispatch(changeScrollMode(value as ScrollMode))}
>
<Group mt="xs">
{Object.entries(scrollModeOptions).map(e => (
<Radio key={e[0]} value={e[0]} label={e[1]} />
))}
</Group>
</Radio.Group>
<NumberInput
label={<Trans>Entries to keep above the selected entry when scrolling</Trans>}
description={<Trans>Only applies to compact, cozy and detailed modes</Trans>}
min={0}
value={entriesToKeepOnTop}
onChange={async value => await dispatch(changeEntriesToKeepOnTopWhenScrolling(+value))}
/>
<Switch
label={<Trans>Scroll smoothly when navigating between entries</Trans>}
checked={scrollSpeed ? scrollSpeed > 0 : false}
onChange={async e => await dispatch(changeScrollSpeed(e.currentTarget.checked))}
/>
<Switch
label={<Trans>In expanded view, scrolling through entries mark them as read</Trans>}
checked={scrollMarks}
onChange={async e => await dispatch(changeScrollMarks(e.currentTarget.checked))}
/>
<Divider label={<Trans>Sharing sites</Trans>} labelPosition="center" />
<SimpleGrid cols={2}>
{(Object.keys(Constants.sharing) as Array<keyof SharingSettings>).map(site => (
<Switch
key={site}
label={Constants.sharing[site].label}
checked={sharingSettings?.[site]}
onChange={async e =>
await dispatch(
changeSharingSetting({
site,
value: e.currentTarget.checked,
})
)
}
/>
))}
</SimpleGrid>
</Stack>
)
}

View File

@@ -1,162 +1,164 @@
import { t, Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core" import { useLingui } from "@lingui/react"
import { useForm } from "@mantine/form" import { Anchor, Box, Button, Checkbox, Divider, Group, Input, PasswordInput, Stack, Text, TextInput } from "@mantine/core"
import { openConfirmModal } from "@mantine/modals" import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client" import { openConfirmModal } from "@mantine/modals"
import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks" import { client, errorToStrings } from "app/client"
import { useAppDispatch, useAppSelector } from "app/store" import { redirectToLogin, redirectToSelectedSource } from "app/redirect/thunks"
import { type ProfileModificationRequest } from "app/types" import { useAppDispatch, useAppSelector } from "app/store"
import { reloadProfile } from "app/user/thunks" import type { ProfileModificationRequest } from "app/types"
import { Alert } from "components/Alert" import { reloadProfile } from "app/user/thunks"
import { useEffect } from "react" import { Alert } from "components/Alert"
import { useAsyncCallback } from "react-async-hook" import { useEffect } from "react"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb" import { useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
interface FormData extends ProfileModificationRequest {
newPasswordConfirmation?: string interface FormData extends ProfileModificationRequest {
} newPasswordConfirmation?: string
}
export function ProfileSettings() {
const profile = useAppSelector(state => state.user.profile) export function ProfileSettings() {
const dispatch = useAppDispatch() const profile = useAppSelector(state => state.user.profile)
const dispatch = useAppDispatch()
const form = useForm<FormData>({ const { _ } = useLingui()
validate: {
newPasswordConfirmation: (value, values) => (value !== values.newPassword ? t`Passwords do not match` : null), const form = useForm<FormData>({
}, validate: {
}) newPasswordConfirmation: (value, values) => (value !== values.newPassword ? _(msg`Passwords do not match`) : null),
const { setValues } = form },
})
const saveProfile = useAsyncCallback(client.user.saveProfile, { const { setValues } = form
onSuccess: () => {
dispatch(reloadProfile()) const saveProfile = useAsyncCallback(client.user.saveProfile, {
dispatch(redirectToSelectedSource()) onSuccess: () => {
}, dispatch(reloadProfile())
}) dispatch(redirectToSelectedSource())
const deleteProfile = useAsyncCallback(client.user.deleteProfile, { },
onSuccess: () => { })
dispatch(redirectToLogin()) const deleteProfile = useAsyncCallback(client.user.deleteProfile, {
}, onSuccess: () => {
}) dispatch(redirectToLogin())
},
const openDeleteProfileModal = () => })
openConfirmModal({
title: <Trans>Delete account</Trans>, const openDeleteProfileModal = () =>
children: ( openConfirmModal({
<Text size="sm"> title: <Trans>Delete account</Trans>,
<Trans>Are you sure you want to delete your account? There's no turning back!</Trans> children: (
</Text> <Text size="sm">
), <Trans>Are you sure you want to delete your account? There's no turning back!</Trans>
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> }, </Text>
confirmProps: { color: "red" }, ),
onConfirm: async () => await deleteProfile.execute(), labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
}) confirmProps: { color: "red" },
onConfirm: async () => await deleteProfile.execute(),
useEffect(() => { })
if (!profile) return
setValues({ useEffect(() => {
currentPassword: "", if (!profile) return
email: profile.email ?? "", setValues({
newApiKey: false, currentPassword: "",
}) email: profile.email ?? "",
}, [setValues, profile]) newApiKey: false,
})
return ( }, [setValues, profile])
<>
{saveProfile.error && ( return (
<Box mb="md"> <>
<Alert messages={errorToStrings(saveProfile.error)} /> {saveProfile.error && (
</Box> <Box mb="md">
)} <Alert messages={errorToStrings(saveProfile.error)} />
</Box>
{deleteProfile.error && ( )}
<Box mb="md">
<Alert messages={errorToStrings(deleteProfile.error)} /> {deleteProfile.error && (
</Box> <Box mb="md">
)} <Alert messages={errorToStrings(deleteProfile.error)} />
</Box>
<form onSubmit={form.onSubmit(saveProfile.execute)}> )}
<Stack>
<TextInput label={<Trans>User name</Trans>} readOnly value={profile?.name} /> <form onSubmit={form.onSubmit(saveProfile.execute)}>
<TextInput <Stack>
label={<Trans>API key</Trans>} <TextInput label={<Trans>User name</Trans>} readOnly value={profile?.name} />
description={ <TextInput
<Trans> label={<Trans>API key</Trans>}
This is your API key. It can be used for some read-only API operations and grants access to the Fever API. description={
Use the form at the bottom of the page to generate a new API key <Trans>
</Trans> This is your API key. It can be used for some read-only API operations and grants access to the Fever API.
} Use the form at the bottom of the page to generate a new API key
readOnly </Trans>
value={profile?.apiKey} }
/> readOnly
value={profile?.apiKey}
<Input.Wrapper />
label={<Trans>OPML export</Trans>}
description={ <Input.Wrapper
<Trans> label={<Trans>OPML export</Trans>}
Export your subscriptions and categories as an OPML file that can be imported in other feed reading services description={
</Trans> <Trans>
} Export your subscriptions and categories as an OPML file that can be imported in other feed reading services
> </Trans>
<Box> }
<Anchor href="rest/feed/export" download="commafeed_opml.xml"> >
<Trans>Download</Trans> <Box>
</Anchor> <Anchor href="rest/feed/export" download="commafeed.opml">
</Box> <Trans>Download</Trans>
</Input.Wrapper> </Anchor>
</Box>
<Input.Wrapper </Input.Wrapper>
label={<Trans>Fever API</Trans>}
description={ <Input.Wrapper
<Trans> label={<Trans>Fever API</Trans>}
CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client. description={
Login with your username and your <u>API key</u>. <Trans>
</Trans> CommaFeed is compatible with the Fever API. Use the following URL in your Fever-compatible mobile client.
} Login with your username and your <u>API key</u>.
> </Trans>
<Box> }
<Anchor href={`rest/fever/user/${profile?.id}`} target="_blank"> >
<Trans>Fever API URL</Trans> <Box>
</Anchor> <Anchor href={`rest/fever/user/${profile?.id}`} target="_blank">
</Box> <Trans>Fever API URL</Trans>
</Input.Wrapper> </Anchor>
</Box>
<Divider /> </Input.Wrapper>
<PasswordInput <Divider />
label={<Trans>Current password</Trans>}
description={<Trans>Enter your current password to change profile settings</Trans>} <PasswordInput
required label={<Trans>Current password</Trans>}
{...form.getInputProps("currentPassword")} description={<Trans>Enter your current password to change profile settings</Trans>}
/> required
<TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required /> {...form.getInputProps("currentPassword")}
<PasswordInput />
label={<Trans>New password</Trans>} <TextInput type="email" label={<Trans>E-mail</Trans>} {...form.getInputProps("email")} required />
description={<Trans>Changing password will generate a new API key</Trans>} <PasswordInput
{...form.getInputProps("newPassword")} label={<Trans>New password</Trans>}
/> description={<Trans>Changing password will generate a new API key</Trans>}
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} /> {...form.getInputProps("newPassword")}
<Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} /> />
<PasswordInput label={<Trans>Confirm password</Trans>} {...form.getInputProps("newPasswordConfirmation")} />
<Group> <Checkbox label={<Trans>Generate new API key</Trans>} {...form.getInputProps("newApiKey", { type: "checkbox" })} />
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans> <Group>
</Button> <Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}> <Trans>Cancel</Trans>
<Trans>Save</Trans> </Button>
</Button> <Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={saveProfile.loading}>
<Divider orientation="vertical" /> <Trans>Save</Trans>
<Button </Button>
color="red" <Divider orientation="vertical" />
leftSection={<TbTrash size={16} />} <Button
onClick={() => openDeleteProfileModal()} color="red"
loading={deleteProfile.loading} leftSection={<TbTrash size={16} />}
> onClick={() => openDeleteProfileModal()}
<Trans>Delete account</Trans> loading={deleteProfile.loading}
</Button> >
</Group> <Trans>Delete account</Trans>
</Stack> </Button>
</form> </Group>
</> </Stack>
) </form>
} </>
)
}

View File

@@ -1,175 +1,194 @@
import { Trans } from "@lingui/macro" import { Trans } from "@lingui/macro"
import { Box, Stack } from "@mantine/core" import { Box, Stack } from "@mantine/core"
import { Constants } from "app/constants" import { Constants } from "app/constants"
import { import {
redirectToCategory, redirectToCategory,
redirectToCategoryDetails, redirectToCategoryDetails,
redirectToFeed, redirectToFeed,
redirectToFeedDetails, redirectToFeedDetails,
redirectToTag, redirectToTag,
redirectToTagDetails, redirectToTagDetails,
} from "app/redirect/thunks" } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store" import { useAppDispatch, useAppSelector } from "app/store"
import { collapseTreeCategory } from "app/tree/thunks" import { collapseTreeCategory } from "app/tree/thunks"
import { type Category, type Subscription } from "app/types" import type { Category, Subscription } from "app/types"
import { categoryUnreadCount, flattenCategoryTree } from "app/utils" import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
import { Loader } from "components/Loader" import { Loader } from "components/Loader"
import { OnDesktop } from "components/responsive/OnDesktop" import { OnDesktop } from "components/responsive/OnDesktop"
import React from "react" import React from "react"
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb" import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { TreeNode } from "./TreeNode" import { TreeNode } from "./TreeNode"
import { TreeSearch } from "./TreeSearch" import { TreeSearch } from "./TreeSearch"
const allIcon = <TbInbox size={16} /> const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} /> const starredIcon = <TbStar size={16} />
const tagIcon = <TbTag size={16} /> const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} /> const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} /> const collapsedIcon = <TbChevronRight size={16} />
const errorThreshold = 9 const errorThreshold = 9
export function Tree() { export function Tree() {
const root = useAppSelector(state => state.tree.rootCategory) const root = useAppSelector(state => state.tree.rootCategory)
const source = useAppSelector(state => state.entries.source) const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags) const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead) const showRead = useAppSelector(state => state.user.settings?.showRead)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const feedClicked = (e: React.MouseEvent, id: string) => { const isFeedDisplayed = (feed: Subscription) => {
if (e.detail === 2) { const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
dispatch(redirectToFeedDetails(id)) return isCurrentFeed || feed.unread > 0 || showRead
} else { }
dispatch(redirectToFeed(id))
} const isCategoryDisplayed = (category: Category): boolean => {
} const isCurrentCategory = source.type === "category" && source.id === category.id
const categoryClicked = (e: React.MouseEvent, id: string) => { return (
if (e.detail === 2) { isCurrentCategory ||
dispatch(redirectToCategoryDetails(id)) showRead ||
} else { category.children.some(c => isCategoryDisplayed(c)) ||
dispatch(redirectToCategory(id)) category.feeds.some(f => isFeedDisplayed(f))
} )
} }
const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation() const feedClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch( dispatch(redirectToFeedDetails(id))
collapseTreeCategory({ } else {
id: +category.id, dispatch(redirectToFeed(id))
collapse: category.expanded, }
}) }
) const categoryClicked = (e: React.MouseEvent, id: string) => {
} if (e.detail === 2) {
const tagClicked = (e: React.MouseEvent, id: string) => { dispatch(redirectToCategoryDetails(id))
if (e.detail === 2) { } else {
dispatch(redirectToTagDetails(id)) dispatch(redirectToCategory(id))
} else { }
dispatch(redirectToTag(id)) }
} const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
} e.stopPropagation()
const allCategoryNode = () => ( dispatch(
<TreeNode collapseTreeCategory({
id={Constants.categories.all.id} id: +category.id,
name={<Trans>All</Trans>} collapse: category.expanded,
icon={allIcon} })
unread={categoryUnreadCount(root)} )
selected={source.type === "category" && source.id === Constants.categories.all.id} }
expanded={false} const tagClicked = (e: React.MouseEvent, id: string) => {
level={0} if (e.detail === 2) {
hasError={false} dispatch(redirectToTagDetails(id))
onClick={categoryClicked} } else {
/> dispatch(redirectToTag(id))
) }
const starredCategoryNode = () => ( }
<TreeNode
id={Constants.categories.starred.id} const allCategoryNode = () => (
name={<Trans>Starred</Trans>} <TreeNode
icon={starredIcon} id={Constants.categories.all.id}
unread={0} type="category"
selected={source.type === "category" && source.id === Constants.categories.starred.id} name={<Trans>All</Trans>}
expanded={false} icon={allIcon}
level={0} unread={categoryUnreadCount(root)}
hasError={false} selected={source.type === "category" && source.id === Constants.categories.all.id}
onClick={categoryClicked} expanded={false}
/> level={0}
) hasError={false}
onClick={categoryClicked}
const categoryNode = (category: Category, level = 0) => { />
const unreadCount = categoryUnreadCount(category) )
if (unreadCount === 0 && !showRead) return null const starredCategoryNode = () => (
<TreeNode
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold)) id={Constants.categories.starred.id}
return ( type="category"
<TreeNode name={<Trans>Starred</Trans>}
id={category.id} icon={starredIcon}
name={category.name} unread={0}
icon={category.expanded ? expandedIcon : collapsedIcon} selected={source.type === "category" && source.id === Constants.categories.starred.id}
unread={unreadCount} expanded={false}
selected={source.type === "category" && source.id === category.id} level={0}
expanded={category.expanded} hasError={false}
level={level} onClick={categoryClicked}
hasError={hasError} />
onClick={categoryClicked} )
onIconClick={e => categoryIconClicked(e, category)}
key={category.id} const categoryNode = (category: Category, level = 0) => {
/> if (!isCategoryDisplayed(category)) return null
)
} const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
return (
const feedNode = (feed: Subscription, level = 0) => { <TreeNode
if (feed.unread === 0 && !showRead) return null id={category.id}
type="category"
return ( name={category.name}
<TreeNode icon={category.expanded ? expandedIcon : collapsedIcon}
id={String(feed.id)} unread={categoryUnreadCount(category)}
name={feed.name} selected={source.type === "category" && source.id === category.id}
icon={feed.iconUrl} expanded={category.expanded}
unread={feed.unread} level={level}
selected={source.type === "feed" && source.id === String(feed.id)} hasError={hasError}
level={level} onClick={categoryClicked}
hasError={feed.errorCount > errorThreshold} onIconClick={e => categoryIconClicked(e, category)}
onClick={feedClicked} key={category.id}
key={feed.id} />
/> )
) }
}
const feedNode = (feed: Subscription, level = 0) => {
const tagNode = (tag: string) => ( if (!isFeedDisplayed(feed)) return null
<TreeNode
id={tag} return (
name={tag} <TreeNode
icon={tagIcon} id={String(feed.id)}
unread={0} type="feed"
selected={source.type === "tag" && source.id === tag} name={feed.name}
level={0} icon={feed.iconUrl}
hasError={false} unread={feed.unread}
onClick={tagClicked} selected={source.type === "feed" && source.id === String(feed.id)}
key={tag} level={level}
/> hasError={feed.errorCount > errorThreshold}
) onClick={feedClicked}
key={feed.id}
const recursiveCategoryNode = (category: Category, level = 0) => ( />
<React.Fragment key={`recursiveCategoryNode-${category.id}`}> )
{categoryNode(category, level)} }
{category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))}
{category.expanded && category.feeds.map(f => feedNode(f, level + 1))} const tagNode = (tag: string) => (
</React.Fragment> <TreeNode
) id={tag}
type="tag"
if (!root) return <Loader /> name={tag}
const feeds = flattenCategoryTree(root).flatMap(c => c.feeds) icon={tagIcon}
return ( unread={0}
<Stack> selected={source.type === "tag" && source.id === tag}
<OnDesktop> level={0}
<TreeSearch feeds={feeds} /> hasError={false}
</OnDesktop> onClick={tagClicked}
<Box> key={tag}
{allCategoryNode()} />
{starredCategoryNode()} )
{root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))} const recursiveCategoryNode = (category: Category, level = 0) => (
{tags?.map(tag => tagNode(tag))} <React.Fragment key={`recursiveCategoryNode-${category.id}`}>
</Box> {categoryNode(category, level)}
</Stack> {category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))}
) {category.expanded && category.feeds.map(f => feedNode(f, level + 1))}
} </React.Fragment>
)
if (!root) return <Loader />
const feeds = flattenCategoryTree(root).flatMap(c => c.feeds)
return (
<Stack>
<OnDesktop>
<TreeSearch feeds={feeds} />
</OnDesktop>
<Box>
{allCategoryNode()}
{starredCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))}
{tags?.map(tag => tagNode(tag))}
</Box>
</Stack>
)
}

View File

@@ -1,78 +1,88 @@
import { Box, Center } from "@mantine/core" import { Box, Center } from "@mantine/core"
import { FeedFavicon } from "components/content/FeedFavicon" import type { EntrySourceType } from "app/entries/slice"
import React, { type ReactNode } from "react" import { FeedFavicon } from "components/content/FeedFavicon"
import { tss } from "tss" import type React from "react"
import { UnreadCount } from "./UnreadCount" import { tss } from "tss"
import { UnreadCount } from "./UnreadCount"
interface TreeNodeProps {
id: string interface TreeNodeProps {
name: ReactNode id: string
icon: ReactNode type: EntrySourceType
unread: number name: React.ReactNode
selected: boolean icon: React.ReactNode
expanded?: boolean unread: number
level: number selected: boolean
hasError: boolean expanded?: boolean
onClick: (e: React.MouseEvent, id: string) => void level: number
onIconClick?: (e: React.MouseEvent, id: string) => void hasError: boolean
} onClick: (e: React.MouseEvent, id: string) => void
onIconClick?: (e: React.MouseEvent, id: string) => void
const useStyles = tss }
.withParams<{
selected: boolean const useStyles = tss
hasError: boolean .withParams<{
hasUnread: boolean selected: boolean
}>() hasError: boolean
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => { hasUnread: boolean
let backgroundColor = "inherit" }>()
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1] .create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
let backgroundColor = "inherit"
let color if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
if (hasError) {
color = theme.colors.red[6] let color: string
} else if (colorScheme === "dark") { if (hasError) {
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3] color = theme.colors.red[6]
} else { } else if (colorScheme === "dark") {
color = hasUnread ? theme.black : theme.colors.gray[6] color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
} } else {
color = hasUnread ? theme.black : theme.colors.gray[6]
return { }
node: {
display: "flex", return {
alignItems: "center", node: {
cursor: "pointer", display: "flex",
color, alignItems: "center",
backgroundColor, cursor: "pointer",
"&:hover": { color,
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], backgroundColor,
}, "&:hover": {
}, backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
nodeText: { },
flexGrow: 1, },
whiteSpace: "nowrap", nodeText: {
overflow: "hidden", flexGrow: 1,
textOverflow: "ellipsis", whiteSpace: "nowrap",
}, overflow: "hidden",
} textOverflow: "ellipsis",
}) },
}
export function TreeNode(props: TreeNodeProps) { })
const { classes } = useStyles({
selected: props.selected, export function TreeNode(props: TreeNodeProps) {
hasError: props.hasError, const { classes } = useStyles({
hasUnread: props.unread > 0, selected: props.selected,
}) hasError: props.hasError,
return ( hasUnread: props.unread > 0,
<Box py={1} pl={props.level * 20} className={classes.node} onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}> })
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}> return (
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center> <Box
</Box> py={1}
<Box className={classes.nodeText}>{props.name}</Box> pl={props.level * 20}
{!props.expanded && ( className={classes.node}
<Box> onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
<UnreadCount unreadCount={props.unread} /> data-id={props.id}
</Box> data-type={props.type}
)} data-unread-count={props.unread}
</Box> >
) <Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}>
} <Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
</Box>
<Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && (
<Box>
<UnreadCount unreadCount={props.unread} />
</Box>
)}
</Box>
)
}

View File

@@ -1,68 +1,62 @@
import { t, Trans } from "@lingui/macro" import { Trans, msg } from "@lingui/macro"
import { Box, Center, Kbd, TextInput } from "@mantine/core" import { useLingui } from "@lingui/react"
import { Spotlight, spotlight, type SpotlightActionData } from "@mantine/spotlight" import { TextInput } from "@mantine/core"
import { redirectToFeed } from "app/redirect/thunks" import { Spotlight, type SpotlightActionData, spotlight } from "@mantine/spotlight"
import { useAppDispatch } from "app/store" import { redirectToFeed } from "app/redirect/thunks"
import { type Subscription } from "app/types" import { useAppDispatch } from "app/store"
import { FeedFavicon } from "components/content/FeedFavicon" import type { Subscription } from "app/types"
import { useMousetrap } from "hooks/useMousetrap" import { FeedFavicon } from "components/content/FeedFavicon"
import { TbSearch } from "react-icons/tb" import { useMousetrap } from "hooks/useMousetrap"
import { TbSearch } from "react-icons/tb"
export interface TreeSearchProps {
feeds: Subscription[] export interface TreeSearchProps {
} feeds: Subscription[]
}
export function TreeSearch(props: TreeSearchProps) {
const dispatch = useAppDispatch() export function TreeSearch(props: TreeSearchProps) {
const dispatch = useAppDispatch()
const actions: SpotlightActionData[] = props.feeds const { _ } = useLingui()
.map(f => ({
id: `${f.id}`, const actions: SpotlightActionData[] = props.feeds
label: f.name, .map(f => ({
leftSection: <FeedFavicon url={f.iconUrl} />, id: `${f.id}`,
onClick: async () => await dispatch(redirectToFeed(f.id)), label: f.name,
})) leftSection: <FeedFavicon url={f.iconUrl} />,
.sort((f1, f2) => f1.label.localeCompare(f2.label)) onClick: async () => await dispatch(redirectToFeed(f.id)),
}))
const searchIcon = <TbSearch size={18} /> .sort((f1, f2) => f1.label.localeCompare(f2.label))
const rightSection = (
<Center style={{ cursor: "pointer" }} onClick={() => spotlight.open()}> const searchIcon = <TbSearch size={18} />
<Kbd>Ctrl</Kbd>
<Box mx={5}>+</Box> // additional keyboard shortcut used by commafeed v1
<Kbd>K</Kbd> useMousetrap("g u", () => spotlight.open())
</Center>
) return (
<>
// additional keyboard shortcut used by commafeed v1 <TextInput
useMousetrap("g u", () => spotlight.open()) placeholder={_(msg`Search`)}
leftSection={searchIcon}
return ( rightSectionWidth={100}
<> styles={{
<TextInput input: {
placeholder={t`Search`} cursor: "pointer",
leftSection={searchIcon} },
rightSectionWidth={100} }}
rightSection={rightSection} onClick={() => spotlight.open()}
styles={{ // prevent focus
input: { onFocus={e => e.target.blur()}
cursor: "pointer", readOnly
}, />
}} <Spotlight
onClick={() => spotlight.open()} actions={actions}
// prevent focus limit={10}
onFocus={e => e.target.blur()} shortcut="mod+k"
readOnly searchProps={{
/> leftSection: searchIcon,
<Spotlight placeholder: _(msg`Search`),
actions={actions} }}
limit={10} nothingFound={<Trans>Nothing found</Trans>}
shortcut="ctrl+k" />
searchProps={{ </>
leftSection: searchIcon, )
placeholder: t`Search`, }
}}
nothingFound={<Trans>Nothing found</Trans>}
></Spotlight>
</>
)
}

View File

@@ -1,25 +1,26 @@
import { Badge, Tooltip } from "@mantine/core" import { Badge, Tooltip } from "@mantine/core"
import { tss } from "tss" import { Constants } from "app/constants"
import { tss } from "tss"
const useStyles = tss.create(() => ({
badge: { const useStyles = tss.create(() => ({
width: "3.2rem", badge: {
// for some reason, mantine Badge has "cursor: 'default'" width: "3.2rem",
cursor: "pointer", // for some reason, mantine Badge has "cursor: 'default'"
}, cursor: "pointer",
})) },
}))
export function UnreadCount(props: { unreadCount: number }) {
const { classes } = useStyles() export function UnreadCount(props: { unreadCount: number }) {
const { classes } = useStyles()
if (props.unreadCount <= 0) return null
if (props.unreadCount <= 0) return null
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
return ( const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count}> return (
<Badge className={classes.badge} variant="light"> <Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
{count} <Badge className={classes.badge} variant="light" fullWidth>
</Badge> {count}
</Tooltip> </Badge>
) </Tooltip>
} )
}

View File

@@ -1,9 +1,9 @@
import { useMantineTheme } from "@mantine/core" import { useMantineTheme } from "@mantine/core"
import { useMobile } from "hooks/useMobile" import { useMobile } from "hooks/useMobile"
export const useActionButton = () => { export const useActionButton = () => {
const theme = useMantineTheme() const theme = useMantineTheme()
const mobile = useMobile(theme.breakpoints.xl) const mobile = useMobile(theme.breakpoints.xl)
const spacing = mobile ? 14 : 0 const spacing = mobile ? 14 : 0
return { mobile, spacing } return { mobile, spacing }
} }

View File

@@ -1,39 +1,41 @@
import { t } from "@lingui/macro" import { msg } from "@lingui/macro"
import { useAppSelector } from "app/store" import { useLingui } from "@lingui/react"
import { useAppSelector } from "app/store"
interface Step {
label: string interface Step {
done: boolean label: string
} done: boolean
}
export const useAppLoading = () => {
const profile = useAppSelector(state => state.user.profile) export const useAppLoading = () => {
const settings = useAppSelector(state => state.user.settings) const profile = useAppSelector(state => state.user.profile)
const rootCategory = useAppSelector(state => state.tree.rootCategory) const settings = useAppSelector(state => state.user.settings)
const tags = useAppSelector(state => state.user.tags) const rootCategory = useAppSelector(state => state.tree.rootCategory)
const tags = useAppSelector(state => state.user.tags)
const steps: Step[] = [ const { _ } = useLingui()
{
label: t`Loading settings...`, const steps: Step[] = [
done: !!settings, {
}, label: _(msg`Loading settings...`),
{ done: !!settings,
label: t`Loading profile...`, },
done: !!profile, {
}, label: _(msg`Loading profile...`),
{ done: !!profile,
label: t`Loading subscriptions...`, },
done: !!rootCategory, {
}, label: _(msg`Loading subscriptions...`),
{ done: !!rootCategory,
label: t`Loading tags...`, },
done: !!tags, {
}, label: _(msg`Loading tags...`),
] done: !!tags,
},
const loading = steps.some(s => !s.done) ]
const loadingPercentage = Math.round((100.0 * steps.filter(s => s.done).length) / steps.length)
const loadingStepLabel = steps.find(s => !s.done)?.label const loading = steps.some(s => !s.done)
const loadingPercentage = Math.round((100.0 * steps.filter(s => s.done).length) / steps.length)
return { steps, loading, loadingPercentage, loadingStepLabel } const loadingStepLabel = steps.find(s => !s.done)?.label
}
return { steps, loading, loadingPercentage, loadingStepLabel }
}

View File

@@ -1,64 +1,64 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
export const useBrowserExtension = () => { export const useBrowserExtension = () => {
// the extension will set the "browser-extension-installed" attribute on the root element // the extension will set the "browser-extension-installed" attribute on the root element
const [browserExtensionVersion, setBrowserExtensionVersion] = useState( const [browserExtensionVersion, setBrowserExtensionVersion] = useState(
document.documentElement.getAttribute("browser-extension-installed") document.documentElement.getAttribute("browser-extension-installed")
) )
// monitor the attribute on the root element as it may change after the page was loaded // monitor the attribute on the root element as it may change after the page was loaded
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(mutations => { const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => { for (const mutation of mutations) {
if (mutation.type === "attributes") { if (mutation.type === "attributes") {
const element = mutation.target as Element const element = mutation.target as Element
const version = element.getAttribute("browser-extension-installed") const version = element.getAttribute("browser-extension-installed")
if (version) setBrowserExtensionVersion(version) if (version) setBrowserExtensionVersion(version)
} }
}) }
}) })
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
attributes: true, attributes: true,
}) })
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
// when not in an iframe, window.parent is a reference to window // when not in an iframe, window.parent is a reference to window
const isBrowserExtensionPopup = window.parent !== window const isBrowserExtensionPopup = window.parent !== window
const isBrowserExtensionInstalled = isBrowserExtensionPopup || !!browserExtensionVersion const isBrowserExtensionInstalled = isBrowserExtensionPopup || !!browserExtensionVersion
const isBrowserExtensionInstallable = !isBrowserExtensionPopup const isBrowserExtensionInstallable = !isBrowserExtensionPopup
const w = isBrowserExtensionPopup ? window.parent : window const w = isBrowserExtensionPopup ? window.parent : window
const openSettingsPage = () => w.postMessage("open-settings-page", "*") const openSettingsPage = () => w.postMessage("open-settings-page", "*")
const openAppInNewTab = () => w.postMessage("open-app-in-new-tab", "*") const openAppInNewTab = () => w.postMessage("open-app-in-new-tab", "*")
const openLinkInBackgroundTab = (url: string) => { const openLinkInBackgroundTab = (url: string) => {
if (isBrowserExtensionInstalled) { if (isBrowserExtensionInstalled) {
w.postMessage(`open-link-in-background-tab:${url}`, "*") w.postMessage(`open-link-in-background-tab:${url}`, "*")
} else { } else {
// fallback to ctrl+click simulation // fallback to ctrl+click simulation
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
a.rel = "noreferrer" a.rel = "noreferrer"
a.dispatchEvent( a.dispatchEvent(
new MouseEvent("click", { new MouseEvent("click", {
ctrlKey: true, ctrlKey: true,
metaKey: true, metaKey: true,
}) })
) )
} }
} }
const setBadgeUnreadCount = (count: number) => w.postMessage(`set-badge-unread-count:${count}`, "*") const setBadgeUnreadCount = (count: number | string) => w.postMessage(`set-badge-unread-count:${count}`, "*")
return { return {
browserExtensionVersion, browserExtensionVersion,
isBrowserExtensionInstallable, isBrowserExtensionInstallable,
isBrowserExtensionInstalled, isBrowserExtensionInstalled,
isBrowserExtensionPopup, isBrowserExtensionPopup,
openSettingsPage, openSettingsPage,
openAppInNewTab, openAppInNewTab,
openLinkInBackgroundTab, openLinkInBackgroundTab,
setBadgeUnreadCount, setBadgeUnreadCount,
} }
} }

View File

@@ -1,20 +1,20 @@
// the color scheme to use to render components // the color scheme to use to render components
import { useMantineColorScheme } from "@mantine/core" import { useMantineColorScheme } from "@mantine/core"
import { useMediaQuery } from "@mantine/hooks" import { useMediaQuery } from "@mantine/hooks"
export const useColorScheme = () => { export const useColorScheme = () => {
const systemColorScheme = useMediaQuery( const systemColorScheme = useMediaQuery(
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
// passing undefined will use window.matchMedia(query) as default value // passing undefined will use window.matchMedia(query) as default value
undefined, undefined,
{ {
// get initial value synchronously and not in useEffect to avoid flash of light theme // get initial value synchronously and not in useEffect to avoid flash of light theme
getInitialValueInEffect: false, getInitialValueInEffect: false,
} }
) )
? "dark" ? "dark"
: "light" : "light"
const { colorScheme } = useMantineColorScheme() const { colorScheme } = useMantineColorScheme()
return colorScheme === "auto" ? systemColorScheme : colorScheme return colorScheme === "auto" ? systemColorScheme : colorScheme
} }

View File

@@ -1,9 +1,9 @@
import { useMediaQuery } from "@mantine/hooks" import { useMediaQuery } from "@mantine/hooks"
import { Constants } from "app/constants" import { Constants } from "app/constants"
export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => { export const useMobile = (breakpoint: string | number = Constants.layout.mobileBreakpoint) => {
const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint const bp = typeof breakpoint === "number" ? `${breakpoint}px` : breakpoint
return !useMediaQuery(`(min-width: ${bp})`, undefined, { return !useMediaQuery(`(min-width: ${bp})`, undefined, {
getInitialValueInEffect: false, getInitialValueInEffect: false,
}) })
} }

View File

@@ -1,22 +1,22 @@
import mousetrap, { type ExtendedKeyboardEvent } from "mousetrap" import mousetrap, { type ExtendedKeyboardEvent } from "mousetrap"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
type Callback = (e: ExtendedKeyboardEvent, combo: string) => void type Callback = (e: ExtendedKeyboardEvent, combo: string) => void
export const useMousetrap = (key: string | string[], callback: Callback) => { export const useMousetrap = (key: string | string[], callback: Callback) => {
// use a ref to avoid unbinding/rebinding every time the callback changes // use a ref to avoid unbinding/rebinding every time the callback changes
const callbackRef = useRef(callback) const callbackRef = useRef(callback)
callbackRef.current = callback callbackRef.current = callback
useEffect(() => { useEffect(() => {
mousetrap.bind(key, (event, combo) => { mousetrap.bind(key, (event, combo) => {
callbackRef.current(event, combo) callbackRef.current(event, combo)
// prevent default behavior // prevent default behavior
return false return false
}) })
return () => { return () => {
mousetrap.unbind(key) mousetrap.unbind(key)
} }
}, [key]) }, [key])
} }

View File

@@ -0,0 +1,10 @@
import { useEffect, useState } from "react"
export const useNow = (interval = 1000): Date => {
const [time, setTime] = useState(new Date())
useEffect(() => {
const t = setInterval(() => setTime(new Date()), interval)
return () => clearInterval(t)
}, [interval])
return time
}

View File

@@ -1,7 +0,0 @@
import { type ViewMode } from "app/types"
import useLocalStorage from "use-local-storage"
export function useViewMode() {
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("view-mode", "detailed")
return { viewMode, setViewMode }
}

View File

@@ -1,49 +1,49 @@
import { setWebSocketConnected } from "app/server/slice" import { setWebSocketConnected } from "app/server/slice"
import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store" import { type AppDispatch, useAppDispatch, useAppSelector } from "app/store"
import { incrementUnreadCount } from "app/tree/slice" import { incrementUnreadCount } from "app/tree/slice"
import { useEffect } from "react" import { useEffect } from "react"
import WebsocketHeartbeatJs from "websocket-heartbeat-js" import WebsocketHeartbeatJs from "websocket-heartbeat-js"
const handleMessage = (dispatch: AppDispatch, message: string) => { const handleMessage = (dispatch: AppDispatch, message: string) => {
const parts = message.split(":") const parts = message.split(":")
const type = parts[0] const type = parts[0]
if (type === "new-feed-entries") { if (type === "new-feed-entries") {
dispatch( dispatch(
incrementUnreadCount({ incrementUnreadCount({
feedId: +parts[1], feedId: +parts[1],
amount: +parts[2], amount: +parts[2],
}) })
) )
} }
} }
export const useWebSocket = () => { export const useWebSocket = () => {
const websocketEnabled = useAppSelector(state => state.server.serverInfos?.websocketEnabled) const websocketEnabled = useAppSelector(state => state.server.serverInfos?.websocketEnabled)
const websocketPingInterval = useAppSelector(state => state.server.serverInfos?.websocketPingInterval) const websocketPingInterval = useAppSelector(state => state.server.serverInfos?.websocketPingInterval)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useEffect(() => { useEffect(() => {
let ws: WebsocketHeartbeatJs | undefined let ws: WebsocketHeartbeatJs | undefined
if (websocketEnabled && websocketPingInterval) { if (websocketEnabled && websocketPingInterval) {
const currentUrl = new URL(window.location.href) const currentUrl = new URL(window.location.href)
const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss" const wsProtocol = currentUrl.protocol === "http:" ? "ws" : "wss"
const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}${currentUrl.pathname}ws` const wsUrl = `${wsProtocol}://${currentUrl.hostname}:${currentUrl.port}${currentUrl.pathname}ws`
ws = new WebsocketHeartbeatJs({ ws = new WebsocketHeartbeatJs({
url: wsUrl, url: wsUrl,
pingMsg: "ping", pingMsg: "ping",
pingTimeout: websocketPingInterval, pingTimeout: websocketPingInterval,
}) })
ws.onopen = () => dispatch(setWebSocketConnected(true)) ws.onopen = () => dispatch(setWebSocketConnected(true))
ws.onclose = () => dispatch(setWebSocketConnected(false)) ws.onclose = () => dispatch(setWebSocketConnected(false))
ws.onmessage = event => { ws.onmessage = event => {
if (typeof event.data === "string") { if (typeof event.data === "string") {
handleMessage(dispatch, event.data) handleMessage(dispatch, event.data)
} }
} }
} }
return () => ws?.close() return () => ws?.close()
}, [dispatch, websocketEnabled, websocketPingInterval]) }, [dispatch, websocketEnabled, websocketPingInterval])
} }

View File

@@ -1,64 +1,64 @@
import { i18n, type Messages } from "@lingui/core" import { type Messages, i18n } from "@lingui/core"
import { useAppSelector } from "app/store" import { useAppSelector } from "app/store"
import dayjs from "dayjs" import dayjs from "dayjs"
import { useEffect } from "react" import { useEffect } from "react"
interface Locale { interface Locale {
key: string key: string
label: string label: string
daysjsImportFn: () => Promise<ILocale> dayjsImportFn: () => Promise<ILocale>
} }
// add an object to the array to add a new locale // add an object to the array to add a new locale
// don't forget to also add it to the 'locales' array in .linguirc // don't forget to also add it to the 'locales' array in lingui.config.ts
export const locales: Locale[] = [ export const locales: Locale[] = [
{ key: "ar", label: "العربية", daysjsImportFn: async () => await import("dayjs/locale/ar") }, { key: "ar", label: "العربية", dayjsImportFn: async () => await import("dayjs/locale/ar") },
{ key: "ca", label: "Català", daysjsImportFn: async () => await import("dayjs/locale/ca") }, { key: "ca", label: "Català", dayjsImportFn: async () => await import("dayjs/locale/ca") },
{ key: "cs", label: "Čeština", daysjsImportFn: async () => await import("dayjs/locale/cs") }, { key: "cs", label: "Čeština", dayjsImportFn: async () => await import("dayjs/locale/cs") },
{ key: "cy", label: "Cymraeg", daysjsImportFn: async () => await import("dayjs/locale/cy") }, { key: "cy", label: "Cymraeg", dayjsImportFn: async () => await import("dayjs/locale/cy") },
{ key: "da", label: "Danish", daysjsImportFn: async () => await import("dayjs/locale/da") }, { key: "da", label: "Danish", dayjsImportFn: async () => await import("dayjs/locale/da") },
{ key: "de", label: "Deutsch", daysjsImportFn: async () => await import("dayjs/locale/de") }, { key: "de", label: "Deutsch", dayjsImportFn: async () => await import("dayjs/locale/de") },
{ key: "en", label: "English", daysjsImportFn: async () => await import("dayjs/locale/en") }, { key: "en", label: "English", dayjsImportFn: async () => await import("dayjs/locale/en") },
{ key: "es", label: "Español", daysjsImportFn: async () => await import("dayjs/locale/es") }, { key: "es", label: "Español", dayjsImportFn: async () => await import("dayjs/locale/es") },
{ key: "fa", label: "فارسی", daysjsImportFn: async () => await import("dayjs/locale/fa") }, { key: "fa", label: "فارسی", dayjsImportFn: async () => await import("dayjs/locale/fa") },
{ key: "fi", label: "Suomi", daysjsImportFn: async () => await import("dayjs/locale/fi") }, { key: "fi", label: "Suomi", dayjsImportFn: async () => await import("dayjs/locale/fi") },
{ key: "fr", label: "Français", daysjsImportFn: async () => await import("dayjs/locale/fr") }, { key: "fr", label: "Français", dayjsImportFn: async () => await import("dayjs/locale/fr") },
{ key: "gl", label: "Galician", daysjsImportFn: async () => await import("dayjs/locale/gl") }, { key: "gl", label: "Galician", dayjsImportFn: async () => await import("dayjs/locale/gl") },
{ key: "hu", label: "Magyar", daysjsImportFn: async () => await import("dayjs/locale/hu") }, { key: "hu", label: "Magyar", dayjsImportFn: async () => await import("dayjs/locale/hu") },
{ key: "id", label: "Indonesian", daysjsImportFn: async () => await import("dayjs/locale/id") }, { key: "id", label: "Indonesian", dayjsImportFn: async () => await import("dayjs/locale/id") },
{ key: "it", label: "Italiano", daysjsImportFn: async () => await import("dayjs/locale/it") }, { key: "it", label: "Italiano", dayjsImportFn: async () => await import("dayjs/locale/it") },
{ key: "ja", label: "日本語", daysjsImportFn: async () => await import("dayjs/locale/ja") }, { key: "ja", label: "日本語", dayjsImportFn: async () => await import("dayjs/locale/ja") },
{ key: "ko", label: "한국어", daysjsImportFn: async () => await import("dayjs/locale/ko") }, { key: "ko", label: "한국어", dayjsImportFn: async () => await import("dayjs/locale/ko") },
{ key: "ms", label: "Bahasa Malaysian", daysjsImportFn: async () => await import("dayjs/locale/ms") }, { key: "ms", label: "Bahasa Malaysian", dayjsImportFn: async () => await import("dayjs/locale/ms") },
{ key: "nb", label: "Norsk (bokmål)", daysjsImportFn: async () => await import("dayjs/locale/nb") }, { key: "nb", label: "Norsk (bokmål)", dayjsImportFn: async () => await import("dayjs/locale/nb") },
{ key: "nl", label: "Nederlands", daysjsImportFn: async () => await import("dayjs/locale/nl") }, { key: "nl", label: "Nederlands", dayjsImportFn: async () => await import("dayjs/locale/nl") },
{ key: "nn", label: "Norsk (nynorsk)", daysjsImportFn: async () => await import("dayjs/locale/nn") }, { key: "nn", label: "Norsk (nynorsk)", dayjsImportFn: async () => await import("dayjs/locale/nn") },
{ key: "pl", label: "Polski", daysjsImportFn: async () => await import("dayjs/locale/pl") }, { key: "pl", label: "Polski", dayjsImportFn: async () => await import("dayjs/locale/pl") },
{ key: "pt", label: "Português", daysjsImportFn: async () => await import("dayjs/locale/pt") }, { key: "pt", label: "Português", dayjsImportFn: async () => await import("dayjs/locale/pt") },
{ key: "ru", label: "Русский", daysjsImportFn: async () => await import("dayjs/locale/ru") }, { key: "ru", label: "Русский", dayjsImportFn: async () => await import("dayjs/locale/ru") },
{ key: "sk", label: "Slovenčina", daysjsImportFn: async () => await import("dayjs/locale/sk") }, { key: "sk", label: "Slovenčina", dayjsImportFn: async () => await import("dayjs/locale/sk") },
{ key: "sv", label: "Svenska", daysjsImportFn: async () => await import("dayjs/locale/sv") }, { key: "sv", label: "Svenska", dayjsImportFn: async () => await import("dayjs/locale/sv") },
{ key: "tr", label: "Türkçe", daysjsImportFn: async () => await import("dayjs/locale/tr") }, { key: "tr", label: "Türkçe", dayjsImportFn: async () => await import("dayjs/locale/tr") },
{ key: "zh", label: "简体中文", daysjsImportFn: async () => await import("dayjs/locale/zh") }, { key: "zh", label: "简体中文", dayjsImportFn: async () => await import("dayjs/locale/zh") },
] ]
function activateLocale(locale: string) { function activateLocale(locale: string) {
// lingui // lingui
import(`./locales/${locale}/messages.po`).then((data: { messages: Messages }) => { import(`./locales/${locale}/messages.po`).then((data: { messages: Messages }) => {
i18n.load(locale, data.messages) i18n.load(locale, data.messages)
i18n.activate(locale) i18n.activate(locale)
}) })
// dayjs // dayjs
locales locales
.find(l => l.key === locale) .find(l => l.key === locale)
?.daysjsImportFn() ?.dayjsImportFn()
.then(() => dayjs.locale(locale)) .then(() => dayjs.locale(locale))
} }
export const useI18n = () => { export const useI18n = () => {
const locale = useAppSelector(state => state.user.settings?.language) const locale = useAppSelector(state => state.user.settings?.language)
useEffect(() => { useEffect(() => {
activateLocale(locale ?? "en") activateLocale(locale ?? "en")
}, [locale]) }, [locale])
} }

View File

@@ -67,6 +67,7 @@ msgstr "إداري"
msgid "All" msgid "All"
msgstr "الكل" msgstr "الكل"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Always" msgid "Always"
msgstr "" msgstr ""
@@ -139,6 +140,10 @@ msgstr ""
msgid "Browser extention" msgid "Browser extention"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Browser tab"
msgstr ""
#: src/components/admin/UserEdit.tsx #: src/components/admin/UserEdit.tsx
#: src/components/content/add/AddCategory.tsx #: src/components/content/add/AddCategory.tsx
#: src/components/content/add/ImportOpml.tsx #: src/components/content/add/ImportOpml.tsx
@@ -175,6 +180,10 @@ msgstr "تأكد من عمل الخلاصة"
msgid "Close menu" msgid "Close menu"
msgstr "" msgstr ""
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Cmd"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "CommaFeed browser extension version {browserExtensionVersion}." msgid "CommaFeed browser extension version {browserExtensionVersion}."
msgstr "" msgstr ""
@@ -312,6 +321,14 @@ msgstr "دخول"
msgid "Enter your current password to change profile settings" msgid "Enter your current password to change profile settings"
msgstr "أدخل كلمة المرور الحالية لتغيير إعدادات ملف التعريف" msgstr "أدخل كلمة المرور الحالية لتغيير إعدادات ملف التعريف"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
#: src/components/Alert.tsx #: src/components/Alert.tsx
msgid "Error" msgid "Error"
msgstr "خطأ" msgstr "خطأ"
@@ -355,14 +372,14 @@ msgstr ""
msgid "Fever API URL" msgid "Fever API URL"
msgstr "" msgstr ""
#: src/components/content/add/ImportOpml.tsx
msgid "file is required"
msgstr "الملف مطلوب"
#: src/pages/app/FeedDetailsPage.tsx #: src/pages/app/FeedDetailsPage.tsx
msgid "Filtering expression" msgid "Filtering expression"
msgstr "تصفية التعبير" msgstr "تصفية التعبير"
#: src/components/header/ProfileMenu.tsx
msgid "Force fetching feeds is not yet available."
msgstr ""
#: src/pages/auth/LoginPage.tsx #: src/pages/auth/LoginPage.tsx
msgid "Forgot password?" msgid "Forgot password?"
msgstr "هل نسيت كلمة المرور؟" msgstr "هل نسيت كلمة المرور؟"
@@ -545,6 +562,7 @@ msgstr "الاسم"
msgid "Navigate to a subscription by entering its name" msgid "Navigate to a subscription by entering its name"
msgstr "انتقل إلى اشتراك بإدخال اسمه" msgstr "انتقل إلى اشتراك بإدخال اسمه"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Never" msgid "Never"
msgstr "" msgstr ""
@@ -574,6 +592,10 @@ msgstr "اختصار العنصر غير المقروء التالي"
msgid "No more entries" msgid "No more entries"
msgstr "لا مزيد من الإدخالات" msgstr "لا مزيد من الإدخالات"
#: src/components/content/ShareButtons.tsx
msgid "No sharing options available."
msgstr ""
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
msgid "Nothing found" msgid "Nothing found"
msgstr "لم يتم العثور على شيء" msgstr "لم يتم العثور على شيء"
@@ -582,10 +604,22 @@ msgstr "لم يتم العثور على شيء"
msgid "Oldest first" msgid "Oldest first"
msgstr "الأقدم أولا" msgstr "الأقدم أولا"
#: src/components/settings/DisplaySettings.tsx
msgid "On desktop"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen" msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx #: src/pages/ErrorPage.tsx
msgid "Oops!" msgid "Oops!"
msgstr "اوووه!" msgstr "اوووه!"
@@ -603,6 +637,7 @@ msgid "Open current entry in a new tab in the background"
msgstr "فتح الإدخال الحالي في علامة تبويب جديدة في الخلفية" msgstr "فتح الإدخال الحالي في علامة تبويب جديدة في الخلفية"
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/OpenExternalLink.tsx
msgid "Open link" msgid "Open link"
msgstr "افتح الرابط" msgstr "افتح الرابط"
@@ -643,6 +678,10 @@ msgstr "تصدير OPML"
msgid "OPML file" msgid "OPML file"
msgstr "ملف OPML" msgstr "ملف OPML"
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file is required"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "Order" msgid "Order"
msgstr "طلب" msgstr "طلب"
@@ -783,6 +822,10 @@ msgstr ""
msgid "Show entry menu (mobile)" msgid "Show entry menu (mobile)"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show external link icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Show feeds and categories with no unread entries" msgid "Show feeds and categories with no unread entries"
msgstr "إظهار موجز ويب والفئات التي لا تحتوي على إدخالات غير مقروءة" msgstr "إظهار موجز ويب والفئات التي لا تحتوي على إدخالات غير مقروءة"
@@ -795,6 +838,18 @@ msgstr "إظهار تعليمات اختصار لوحة المفاتيح"
msgid "Show native menu (desktop)" msgid "Show native menu (desktop)"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show star icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab favicon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab title"
msgstr ""
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/WelcomePage.tsx #: src/pages/WelcomePage.tsx
@@ -812,6 +867,7 @@ msgstr "فضاء"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Star" msgid "Star"
msgstr "النجم" msgstr "النجم"
@@ -897,6 +953,7 @@ msgstr "غير مقروءة"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Unstar" msgid "Unstar"
msgstr "إلغاء النجم" msgstr "إلغاء النجم"

View File

@@ -67,6 +67,7 @@ msgstr "Administrador"
msgid "All" msgid "All"
msgstr "Tot" msgstr "Tot"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Always" msgid "Always"
msgstr "Sempre" msgstr "Sempre"
@@ -139,6 +140,10 @@ msgstr "Extensió del navegador necessària per a Chrome"
msgid "Browser extention" msgid "Browser extention"
msgstr "Extensió del navegador" msgstr "Extensió del navegador"
#: src/components/settings/DisplaySettings.tsx
msgid "Browser tab"
msgstr ""
#: src/components/admin/UserEdit.tsx #: src/components/admin/UserEdit.tsx
#: src/components/content/add/AddCategory.tsx #: src/components/content/add/AddCategory.tsx
#: src/components/content/add/ImportOpml.tsx #: src/components/content/add/ImportOpml.tsx
@@ -175,6 +180,10 @@ msgstr "Comproveu que el canal funciona"
msgid "Close menu" msgid "Close menu"
msgstr "Tanca el menu" msgstr "Tanca el menu"
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Cmd"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "CommaFeed browser extension version {browserExtensionVersion}." msgid "CommaFeed browser extension version {browserExtensionVersion}."
msgstr "Versió de l'extensió del navegador CommaFeed {browserExtensionVersion}." msgstr "Versió de l'extensió del navegador CommaFeed {browserExtensionVersion}."
@@ -312,6 +321,14 @@ msgstr "Entra"
msgid "Enter your current password to change profile settings" msgid "Enter your current password to change profile settings"
msgstr "introduïu la vostra contrasenya actual per canviar la configuració del perfil" msgstr "introduïu la vostra contrasenya actual per canviar la configuració del perfil"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
#: src/components/Alert.tsx #: src/components/Alert.tsx
msgid "Error" msgid "Error"
msgstr "Error" msgstr "Error"
@@ -355,14 +372,14 @@ msgstr ""
msgid "Fever API URL" msgid "Fever API URL"
msgstr "" msgstr ""
#: src/components/content/add/ImportOpml.tsx
msgid "file is required"
msgstr "el fitxer és necessari"
#: src/pages/app/FeedDetailsPage.tsx #: src/pages/app/FeedDetailsPage.tsx
msgid "Filtering expression" msgid "Filtering expression"
msgstr "Expressió de filtratge" msgstr "Expressió de filtratge"
#: src/components/header/ProfileMenu.tsx
msgid "Force fetching feeds is not yet available."
msgstr ""
#: src/pages/auth/LoginPage.tsx #: src/pages/auth/LoginPage.tsx
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Heu oblidat la contrasenya?" msgstr "Heu oblidat la contrasenya?"
@@ -545,6 +562,7 @@ msgstr "Nom"
msgid "Navigate to a subscription by entering its name" msgid "Navigate to a subscription by entering its name"
msgstr "Navegueu a una subscripció introduint-ne el nom" msgstr "Navegueu a una subscripció introduint-ne el nom"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Never" msgid "Never"
msgstr "Mai" msgstr "Mai"
@@ -574,6 +592,10 @@ msgstr "Següent marcador d'elements no llegit"
msgid "No more entries" msgid "No more entries"
msgstr "No hi ha més entrades" msgstr "No hi ha més entrades"
#: src/components/content/ShareButtons.tsx
msgid "No sharing options available."
msgstr ""
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
msgid "Nothing found" msgid "Nothing found"
msgstr "No s'ha trobat res" msgstr "No s'ha trobat res"
@@ -582,10 +604,22 @@ msgstr "No s'ha trobat res"
msgid "Oldest first" msgid "Oldest first"
msgstr "el més vell primer" msgstr "el més vell primer"
#: src/components/settings/DisplaySettings.tsx
msgid "On desktop"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen" msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "Al mòbil, mostra els botons d'acció a la part inferior de la pantalla" msgstr "Al mòbil, mostra els botons d'acció a la part inferior de la pantalla"
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx #: src/pages/ErrorPage.tsx
msgid "Oops!" msgid "Oops!"
msgstr "Vaja!" msgstr "Vaja!"
@@ -603,6 +637,7 @@ msgid "Open current entry in a new tab in the background"
msgstr "Obre l'entrada actual en una pestanya nova al fons" msgstr "Obre l'entrada actual en una pestanya nova al fons"
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/OpenExternalLink.tsx
msgid "Open link" msgid "Open link"
msgstr "Obre l'enllaç obert" msgstr "Obre l'enllaç obert"
@@ -643,6 +678,10 @@ msgstr "Exportació OPML"
msgid "OPML file" msgid "OPML file"
msgstr "Fitxer OPML" msgstr "Fitxer OPML"
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file is required"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "Order" msgid "Order"
msgstr "Ordre" msgstr "Ordre"
@@ -783,6 +822,10 @@ msgstr "Mostra el menú d'entrada (escriptori)"
msgid "Show entry menu (mobile)" msgid "Show entry menu (mobile)"
msgstr "Mostra el menú d'entrada (mòbil)" msgstr "Mostra el menú d'entrada (mòbil)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show external link icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Show feeds and categories with no unread entries" msgid "Show feeds and categories with no unread entries"
msgstr "Mostra feeds i categories sense entrades no llegides" msgstr "Mostra feeds i categories sense entrades no llegides"
@@ -795,6 +838,18 @@ msgstr "Mostra l'ajuda de la drecera del teclat"
msgid "Show native menu (desktop)" msgid "Show native menu (desktop)"
msgstr "Mostra el menú natiu (escriptori)" msgstr "Mostra el menú natiu (escriptori)"
#: src/components/settings/DisplaySettings.tsx
msgid "Show star icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab favicon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab title"
msgstr ""
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/WelcomePage.tsx #: src/pages/WelcomePage.tsx
@@ -812,6 +867,7 @@ msgstr "Espai"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Star" msgid "Star"
msgstr "Estrella" msgstr "Estrella"
@@ -897,6 +953,7 @@ msgstr "Sense llegir"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Unstar" msgid "Unstar"
msgstr "Desestrellar" msgstr "Desestrellar"

View File

@@ -67,6 +67,7 @@ msgstr "Správce"
msgid "All" msgid "All"
msgstr "Všechny" msgstr "Všechny"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Always" msgid "Always"
msgstr "" msgstr ""
@@ -139,6 +140,10 @@ msgstr ""
msgid "Browser extention" msgid "Browser extention"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Browser tab"
msgstr ""
#: src/components/admin/UserEdit.tsx #: src/components/admin/UserEdit.tsx
#: src/components/content/add/AddCategory.tsx #: src/components/content/add/AddCategory.tsx
#: src/components/content/add/ImportOpml.tsx #: src/components/content/add/ImportOpml.tsx
@@ -175,6 +180,10 @@ msgstr "Zkontrolujte, zda zdroj funguje"
msgid "Close menu" msgid "Close menu"
msgstr "" msgstr ""
#: src/components/KeyboardShortcutsHelp.tsx
msgid "Cmd"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "CommaFeed browser extension version {browserExtensionVersion}." msgid "CommaFeed browser extension version {browserExtensionVersion}."
msgstr "" msgstr ""
@@ -312,6 +321,14 @@ msgstr "Vstupte"
msgid "Enter your current password to change profile settings" msgid "Enter your current password to change profile settings"
msgstr "Zadejte své aktuální heslo pro změnu nastavení profilu" msgstr "Zadejte své aktuální heslo pro změnu nastavení profilu"
#: src/components/settings/DisplaySettings.tsx
msgid "Entries to keep above the selected entry when scrolling"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Entry headers"
msgstr ""
#: src/components/Alert.tsx #: src/components/Alert.tsx
msgid "Error" msgid "Error"
msgstr "Chyba" msgstr "Chyba"
@@ -355,14 +372,14 @@ msgstr ""
msgid "Fever API URL" msgid "Fever API URL"
msgstr "" msgstr ""
#: src/components/content/add/ImportOpml.tsx
msgid "file is required"
msgstr ""
#: src/pages/app/FeedDetailsPage.tsx #: src/pages/app/FeedDetailsPage.tsx
msgid "Filtering expression" msgid "Filtering expression"
msgstr "Filtrování výrazu" msgstr "Filtrování výrazu"
#: src/components/header/ProfileMenu.tsx
msgid "Force fetching feeds is not yet available."
msgstr ""
#: src/pages/auth/LoginPage.tsx #: src/pages/auth/LoginPage.tsx
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Zapomněli jste heslo?" msgstr "Zapomněli jste heslo?"
@@ -545,6 +562,7 @@ msgstr "Jméno"
msgid "Navigate to a subscription by entering its name" msgid "Navigate to a subscription by entering its name"
msgstr "Přejděte na předplatné zadáním jeho názvu" msgstr "Přejděte na předplatné zadáním jeho názvu"
#: src/components/settings/DisplaySettings.tsx
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Never" msgid "Never"
msgstr "" msgstr ""
@@ -574,6 +592,10 @@ msgstr "Další nepřečtená položka bookmarklet"
msgid "No more entries" msgid "No more entries"
msgstr "Žádné další záznamy" msgstr "Žádné další záznamy"
#: src/components/content/ShareButtons.tsx
msgid "No sharing options available."
msgstr ""
#: src/components/sidebar/TreeSearch.tsx #: src/components/sidebar/TreeSearch.tsx
msgid "Nothing found" msgid "Nothing found"
msgstr "Nic nebylo nalezeno" msgstr "Nic nebylo nalezeno"
@@ -582,10 +604,22 @@ msgstr "Nic nebylo nalezeno"
msgid "Oldest first" msgid "Oldest first"
msgstr "Nejdříve nejstarší" msgstr "Nejdříve nejstarší"
#: src/components/settings/DisplaySettings.tsx
msgid "On desktop"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "On mobile"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "On mobile, show action buttons at the bottom of the screen" msgid "On mobile, show action buttons at the bottom of the screen"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Only applies to compact, cozy and detailed modes"
msgstr ""
#: src/pages/ErrorPage.tsx #: src/pages/ErrorPage.tsx
msgid "Oops!" msgid "Oops!"
msgstr "Jejda!" msgstr "Jejda!"
@@ -603,6 +637,7 @@ msgid "Open current entry in a new tab in the background"
msgstr "Otevřít aktuální položku na nové kartě na pozadí" msgstr "Otevřít aktuální položku na nové kartě na pozadí"
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/OpenExternalLink.tsx
msgid "Open link" msgid "Open link"
msgstr "Otevřít odkaz" msgstr "Otevřít odkaz"
@@ -643,6 +678,10 @@ msgstr "Export OPML"
msgid "OPML file" msgid "OPML file"
msgstr "soubor OPML" msgstr "soubor OPML"
#: src/components/content/add/ImportOpml.tsx
msgid "OPML file is required"
msgstr ""
#: src/pages/app/AboutPage.tsx #: src/pages/app/AboutPage.tsx
msgid "Order" msgid "Order"
msgstr "Objednávka" msgstr "Objednávka"
@@ -783,6 +822,10 @@ msgstr ""
msgid "Show entry menu (mobile)" msgid "Show entry menu (mobile)"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show external link icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx #: src/components/settings/DisplaySettings.tsx
msgid "Show feeds and categories with no unread entries" msgid "Show feeds and categories with no unread entries"
msgstr "Zobrazit kanály a kategorie bez nepřečtených položek" msgstr "Zobrazit kanály a kategorie bez nepřečtených položek"
@@ -795,6 +838,18 @@ msgstr "Zobrazit nápovědu ke klávesovým zkratkám"
msgid "Show native menu (desktop)" msgid "Show native menu (desktop)"
msgstr "" msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show star icon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab favicon"
msgstr ""
#: src/components/settings/DisplaySettings.tsx
msgid "Show unread count in tab title"
msgstr ""
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/auth/RegistrationPage.tsx #: src/pages/auth/RegistrationPage.tsx
#: src/pages/WelcomePage.tsx #: src/pages/WelcomePage.tsx
@@ -812,6 +867,7 @@ msgstr "Vesmír"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Star" msgid "Star"
msgstr "Hvězda" msgstr "Hvězda"
@@ -897,6 +953,7 @@ msgstr "Nepřečteno"
#: src/components/content/FeedEntryContextMenu.tsx #: src/components/content/FeedEntryContextMenu.tsx
#: src/components/content/FeedEntryFooter.tsx #: src/components/content/FeedEntryFooter.tsx
#: src/components/content/header/Star.tsx
msgid "Unstar" msgid "Unstar"
msgstr "Odstranit hvězdu" msgstr "Odstranit hvězdu"

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