From e657a96d09978e20874271d8ec5f26df90c774dd Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 29 Dec 2024 12:06:11 +0100 Subject: [PATCH 01/19] client: Fix ordering of functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some reason, ESLint’s `no-unused-vars` rule did not catch that `PureApp` calls `EntriesFilter` until we switched to TypeScript. --- client/js/templates/App.jsx | 74 ++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/client/js/templates/App.jsx b/client/js/templates/App.jsx index ef1799399..5e21fc640 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.jsx @@ -119,6 +119,43 @@ CheckAuthorization.propTypes = { children: PropTypes.any, }; +// Work around for regex patterns not being supported +// https://github.com/remix-run/react-router/issues/8254 +function EntriesFilter({ + entriesRef, + setNavExpanded, + configuration, + navSourcesExpanded, + unreadItemsCount, + setGlobalUnreadCount, +}) { + const params = useEntriesParams(); + + if (params === null) { + return ; + } + + return ( + + ); +} + +EntriesFilter.propTypes = { + entriesRef: PropTypes.func.isRequired, + configuration: PropTypes.object.isRequired, + setNavExpanded: PropTypes.func.isRequired, + navSourcesExpanded: PropTypes.bool.isRequired, + setGlobalUnreadCount: PropTypes.func.isRequired, + unreadItemsCount: PropTypes.number.isRequired, +}; + function PureApp({ navSourcesExpanded, setNavSourcesExpanded, @@ -385,43 +422,6 @@ PureApp.propTypes = { reloadAll: PropTypes.func.isRequired, }; -// Work around for regex patterns not being supported -// https://github.com/remix-run/react-router/issues/8254 -function EntriesFilter({ - entriesRef, - setNavExpanded, - configuration, - navSourcesExpanded, - unreadItemsCount, - setGlobalUnreadCount, -}) { - const params = useEntriesParams(); - - if (params === null) { - return ; - } - - return ( - - ); -} - -EntriesFilter.propTypes = { - entriesRef: PropTypes.func.isRequired, - configuration: PropTypes.object.isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setGlobalUnreadCount: PropTypes.func.isRequired, - unreadItemsCount: PropTypes.number.isRequired, -}; - export class App extends React.Component { constructor(props) { super(props); From 681913617adb26b905e40a2d62ddb711954cf43a Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 29 Dec 2024 12:49:08 +0100 Subject: [PATCH 02/19] controllers/Tags: Fix comment about content type --- src/controllers/Tags.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/Tags.php b/src/controllers/Tags.php index a57e05306..2de92ae82 100644 --- a/src/controllers/Tags.php +++ b/src/controllers/Tags.php @@ -98,7 +98,7 @@ public function color(): void { /** * returns all tags - * html + * json */ public function listTags(): void { $this->authentication->ensureCanRead(); From f57fc703e149c7d43846fade89365a597adb5211 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 29 Dec 2024 12:45:18 +0100 Subject: [PATCH 03/19] client: Fix regression in navigation react-router 6 expects state to be passed in the `state` property of the second argument. I accidentally broke that in 88deedea994efe4cdf97207deed9a292057da80a. We really need static typing. --- client/js/templates/App.jsx | 2 +- client/js/templates/EntriesPage.jsx | 12 +++++------- client/js/templates/HashPassword.jsx | 6 ++++-- client/js/templates/OpmlImport.jsx | 6 ++++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/client/js/templates/App.jsx b/client/js/templates/App.jsx index 5e21fc640..be8973470 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.jsx @@ -97,7 +97,7 @@ function CheckAuthorization({ isAllowed, returnLocation, _, children }) { /\{(?:link_begin|link_end)\}/, ); navigate('/sign/in', { - returnLocation, + state: { returnLocation }, }); return ( diff --git a/client/js/templates/EntriesPage.jsx b/client/js/templates/EntriesPage.jsx index 58dad0a78..cdbbb657b 100644 --- a/client/js/templates/EntriesPage.jsx +++ b/client/js/templates/EntriesPage.jsx @@ -138,7 +138,9 @@ function reloadList({ error.response.status === 403 ) { navigate('/sign/in', { - error: selfoss.app._('error_session_expired'), + state: { + error: selfoss.app._('error_session_expired'), + }, }); return; } @@ -1071,12 +1073,8 @@ class StateHolder extends React.Component { * HACK: A counter that is increased every time reload action (r key) is triggered. */ this.props.navigate( - { - ...this.props.location, - ...makeEntriesLinkLocation(this.props.location, { id: null }), - state: forceReload(this.props.location), - }, - { replace: true }, + makeEntriesLinkLocation(this.props.location, { id: null }), + { replace: true, state: forceReload(this.props.location) }, ); } diff --git a/client/js/templates/HashPassword.jsx b/client/js/templates/HashPassword.jsx index de45c81c3..6b044c56d 100644 --- a/client/js/templates/HashPassword.jsx +++ b/client/js/templates/HashPassword.jsx @@ -30,8 +30,10 @@ export default function HashPassword({ setTitle }) { error.response.status === 403 ) { navigate('/sign/in', { - error: 'Generating a new password hash requires being logged in or not setting “password” in selfoss configuration.', - returnLocation: '/password', + state: { + error: 'Generating a new password hash requires being logged in or not setting “password” in selfoss configuration.', + returnLocation: '/password', + }, }); return; } diff --git a/client/js/templates/OpmlImport.jsx b/client/js/templates/OpmlImport.jsx index 4d8f8d7a7..343c7d77a 100644 --- a/client/js/templates/OpmlImport.jsx +++ b/client/js/templates/OpmlImport.jsx @@ -75,8 +75,10 @@ export default function OpmlImport({ setTitle }) { error.response.status === 403 ) { navigate('/sign/in', { - error: 'Importing OPML file requires being logged in or not setting “password” in selfoss configuration.', - returnLocation: '/opml', + state: { + error: 'Importing OPML file requires being logged in or not setting “password” in selfoss configuration.', + returnLocation: '/opml', + }, }); return; } else { From a6785931a7589ad7079ce0de7dd5285588cd8105 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Wed, 26 Jul 2023 22:27:14 +0200 Subject: [PATCH 04/19] client: Port to TypeScript React 19 removes propTypes, which were deprecated since 2017: https://19.react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-deprecated-react-apis The recommended replacement is using TypeScript. Given that the code base is a mess but dynamic nature of JavaScript gives us little hope of cleaning it without introducing a ton of regressions I have decided to bite the bullet. In this commit, I mostly used https://www.npmjs.com/package/js-to-ts-converter Additionally, I had to perform the following manually changes: - Update references in `package.json`, `index.html` and `selfoss-base`. - Switch to `typescript-eslint`. - Declare `selfoss` inside `window`. --- client/eslint.config.js | 10 +- client/images/{wallabag.js => wallabag.ts} | 0 client/index.html | 2 +- client/js/{Filter.js => Filter.ts} | 0 client/js/{errors.js => errors.ts} | 10 + ...{ValueListenable.js => ValueListenable.ts} | 5 + client/js/helpers/{ajax.js => ajax.ts} | 0 .../{authorizations.js => authorizations.ts} | 0 client/js/helpers/{color.js => color.ts} | 0 .../{configuration.js => configuration.ts} | 0 client/js/helpers/{hooks.js => hooks.ts} | 0 client/js/helpers/{i18n.js => i18n.ts} | 0 .../helpers/{navigation.js => navigation.ts} | 0 client/js/helpers/{uri.js => uri.ts} | 0 client/js/{icons.js => icons.ts} | 0 client/js/{index.js => index.ts} | 6 + client/js/{locales.js => locales.ts} | 0 .../{LoadingState.js => LoadingState.ts} | 0 client/js/requests/{common.js => common.ts} | 2 + client/js/requests/{items.js => items.ts} | 0 client/js/requests/{sources.js => sources.ts} | 0 client/js/requests/{tags.js => tags.ts} | 0 .../js/{selfoss-base.js => selfoss-base.ts} | 2 +- ...ss-db-offline.js => selfoss-db-offline.ts} | 3 +- ...foss-db-online.js => selfoss-db-online.ts} | 0 client/js/{selfoss-db.js => selfoss-db.ts} | 0 client/js/{sharers.jsx => sharers.tsx} | 0 client/js/{shortcuts.js => shortcuts.ts} | 0 client/js/templates/{App.jsx => App.tsx} | 6 +- .../{ColorChooser.jsx => ColorChooser.tsx} | 0 .../{EntriesPage.jsx => EntriesPage.tsx} | 4 +- .../{HashPassword.jsx => HashPassword.tsx} | 0 client/js/templates/{Item.jsx => Item.tsx} | 0 .../{LoginForm.jsx => LoginForm.tsx} | 0 .../{NavFilters.jsx => NavFilters.tsx} | 0 .../{NavSearch.jsx => NavSearch.tsx} | 0 .../{NavSources.jsx => NavSources.tsx} | 0 .../js/templates/{NavTags.jsx => NavTags.tsx} | 0 .../{NavToolBar.jsx => NavToolBar.tsx} | 0 .../{Navigation.jsx => Navigation.tsx} | 0 .../{OpmlImport.jsx => OpmlImport.tsx} | 0 .../{SearchList.jsx => SearchList.tsx} | 0 .../js/templates/{Source.jsx => Source.tsx} | 0 .../{SourceParam.jsx => SourceParam.tsx} | 0 .../{SourcesPage.jsx => SourcesPage.tsx} | 2 +- .../js/templates/{Spinner.jsx => Spinner.tsx} | 0 client/package-lock.json | 267 +++++++++++++++++- client/package.json | 6 +- ...ss-sw-offline.js => selfoss-sw-offline.ts} | 0 client/tsconfig.json | 111 ++++++++ 50 files changed, 423 insertions(+), 13 deletions(-) rename client/images/{wallabag.js => wallabag.ts} (100%) rename client/js/{Filter.js => Filter.ts} (100%) rename client/js/{errors.js => errors.ts} (87%) rename client/js/helpers/{ValueListenable.js => ValueListenable.ts} (87%) rename client/js/helpers/{ajax.js => ajax.ts} (100%) rename client/js/helpers/{authorizations.js => authorizations.ts} (100%) rename client/js/helpers/{color.js => color.ts} (100%) rename client/js/helpers/{configuration.js => configuration.ts} (100%) rename client/js/helpers/{hooks.js => hooks.ts} (100%) rename client/js/helpers/{i18n.js => i18n.ts} (100%) rename client/js/helpers/{navigation.js => navigation.ts} (100%) rename client/js/helpers/{uri.js => uri.ts} (100%) rename client/js/{icons.js => icons.ts} (100%) rename client/js/{index.js => index.ts} (77%) rename client/js/{locales.js => locales.ts} (100%) rename client/js/requests/{LoadingState.js => LoadingState.ts} (100%) rename client/js/requests/{common.js => common.ts} (98%) rename client/js/requests/{items.js => items.ts} (100%) rename client/js/requests/{sources.js => sources.ts} (100%) rename client/js/requests/{tags.js => tags.ts} (100%) rename client/js/{selfoss-base.js => selfoss-base.ts} (99%) rename client/js/{selfoss-db-offline.js => selfoss-db-offline.ts} (99%) rename client/js/{selfoss-db-online.js => selfoss-db-online.ts} (100%) rename client/js/{selfoss-db.js => selfoss-db.ts} (100%) rename client/js/{sharers.jsx => sharers.tsx} (100%) rename client/js/{shortcuts.js => shortcuts.ts} (100%) rename client/js/templates/{App.jsx => App.tsx} (99%) rename client/js/templates/{ColorChooser.jsx => ColorChooser.tsx} (100%) rename client/js/templates/{EntriesPage.jsx => EntriesPage.tsx} (99%) rename client/js/templates/{HashPassword.jsx => HashPassword.tsx} (100%) rename client/js/templates/{Item.jsx => Item.tsx} (100%) rename client/js/templates/{LoginForm.jsx => LoginForm.tsx} (100%) rename client/js/templates/{NavFilters.jsx => NavFilters.tsx} (100%) rename client/js/templates/{NavSearch.jsx => NavSearch.tsx} (100%) rename client/js/templates/{NavSources.jsx => NavSources.tsx} (100%) rename client/js/templates/{NavTags.jsx => NavTags.tsx} (100%) rename client/js/templates/{NavToolBar.jsx => NavToolBar.tsx} (100%) rename client/js/templates/{Navigation.jsx => Navigation.tsx} (100%) rename client/js/templates/{OpmlImport.jsx => OpmlImport.tsx} (100%) rename client/js/templates/{SearchList.jsx => SearchList.tsx} (100%) rename client/js/templates/{Source.jsx => Source.tsx} (100%) rename client/js/templates/{SourceParam.jsx => SourceParam.tsx} (100%) rename client/js/templates/{SourcesPage.jsx => SourcesPage.tsx} (99%) rename client/js/templates/{Spinner.jsx => Spinner.tsx} (100%) rename client/{selfoss-sw-offline.js => selfoss-sw-offline.ts} (100%) create mode 100644 client/tsconfig.json diff --git a/client/eslint.config.js b/client/eslint.config.js index ba70a2bea..19ad6770c 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -3,6 +3,7 @@ import js from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier'; import eslintPluginReact from 'eslint-plugin-react'; import eslintPluginReactHooks from 'eslint-plugin-react-hooks'; +import tseslint from 'typescript-eslint'; const config = { languageOptions: { @@ -22,7 +23,7 @@ const config = { }, }, - files: ['**/*.js', '**/*.jsx'], + files: ['**/*.ts', '**/*.tsx', '**/*.js'], rules: { 'no-eval': 'error', @@ -38,6 +39,8 @@ const config = { ], 'unicode-bom': 'error', + + '@typescript-eslint/no-explicit-any': 0, }, }; @@ -50,10 +53,11 @@ const eslintPluginReactHooksConfigsRecommended = { rules: eslintPluginReactHooks.configs.recommended.rules, }; -export default [ +export default tseslint.config( js.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReactHooksConfigsRecommended, eslintConfigPrettier, + tseslint.configs.recommended, config, -]; +); diff --git a/client/images/wallabag.js b/client/images/wallabag.ts similarity index 100% rename from client/images/wallabag.js rename to client/images/wallabag.ts diff --git a/client/index.html b/client/index.html index ecb251cbb..6000b9e68 100644 --- a/client/index.html +++ b/client/index.html @@ -42,6 +42,6 @@ document.getElementById('js-loading-message').textContent = 'selfoss is still loading, please wait.'; - + diff --git a/client/js/Filter.js b/client/js/Filter.ts similarity index 100% rename from client/js/Filter.js rename to client/js/Filter.ts diff --git a/client/js/errors.js b/client/js/errors.ts similarity index 87% rename from client/js/errors.js rename to client/js/errors.ts index bc625ea06..823c83e82 100644 --- a/client/js/errors.js +++ b/client/js/errors.ts @@ -1,4 +1,6 @@ export class OfflineStorageNotAvailableError extends Error { + public name: any; + constructor(message = 'Offline storage is not available') { super(message); this.name = 'OfflineStorageNotAvailableError'; @@ -6,6 +8,8 @@ export class OfflineStorageNotAvailableError extends Error { } export class TimeoutError extends Error { + public name: any; + constructor(message) { super(message); this.name = 'TimeoutError'; @@ -13,6 +17,8 @@ export class TimeoutError extends Error { } export class HttpError extends Error { + public name: any; + constructor(message) { super(message); this.name = 'HttpError'; @@ -20,6 +26,8 @@ export class HttpError extends Error { } export class LoginError extends Error { + public name: any; + constructor(message) { super(message); this.name = 'LoginError'; @@ -27,6 +35,8 @@ export class LoginError extends Error { } export class UnexpectedStateError extends Error { + public name: any; + constructor(message) { super(message); this.name = 'UnexpectedStateError'; diff --git a/client/js/helpers/ValueListenable.js b/client/js/helpers/ValueListenable.ts similarity index 87% rename from client/js/helpers/ValueListenable.js rename to client/js/helpers/ValueListenable.ts index 0ee151fb5..3f174b368 100644 --- a/client/js/helpers/ValueListenable.js +++ b/client/js/helpers/ValueListenable.ts @@ -1,4 +1,6 @@ export class ValueChangeEvent extends Event { + public value: any; + constructor(value) { super('change'); this.value = value; @@ -9,6 +11,9 @@ export class ValueChangeEvent extends Event { * Object storing a value and allowing subscribing to its changes. */ export class ValueListenable extends EventTarget { + public value: any; + public dispatchEvent: any; + constructor(value) { super(); diff --git a/client/js/helpers/ajax.js b/client/js/helpers/ajax.ts similarity index 100% rename from client/js/helpers/ajax.js rename to client/js/helpers/ajax.ts diff --git a/client/js/helpers/authorizations.js b/client/js/helpers/authorizations.ts similarity index 100% rename from client/js/helpers/authorizations.js rename to client/js/helpers/authorizations.ts diff --git a/client/js/helpers/color.js b/client/js/helpers/color.ts similarity index 100% rename from client/js/helpers/color.js rename to client/js/helpers/color.ts diff --git a/client/js/helpers/configuration.js b/client/js/helpers/configuration.ts similarity index 100% rename from client/js/helpers/configuration.js rename to client/js/helpers/configuration.ts diff --git a/client/js/helpers/hooks.js b/client/js/helpers/hooks.ts similarity index 100% rename from client/js/helpers/hooks.js rename to client/js/helpers/hooks.ts diff --git a/client/js/helpers/i18n.js b/client/js/helpers/i18n.ts similarity index 100% rename from client/js/helpers/i18n.js rename to client/js/helpers/i18n.ts diff --git a/client/js/helpers/navigation.js b/client/js/helpers/navigation.ts similarity index 100% rename from client/js/helpers/navigation.js rename to client/js/helpers/navigation.ts diff --git a/client/js/helpers/uri.js b/client/js/helpers/uri.ts similarity index 100% rename from client/js/helpers/uri.js rename to client/js/helpers/uri.ts diff --git a/client/js/icons.js b/client/js/icons.ts similarity index 100% rename from client/js/icons.js rename to client/js/icons.ts diff --git a/client/js/index.js b/client/js/index.ts similarity index 77% rename from client/js/index.js rename to client/js/index.ts index 9c4c402a2..046531320 100644 --- a/client/js/index.js +++ b/client/js/index.ts @@ -6,5 +6,11 @@ import './selfoss-db'; selfoss.init(); +declare global { + interface Window { + selfoss: base; + } +} + // make selfoss available in console for debugging window.selfoss = selfoss; diff --git a/client/js/locales.js b/client/js/locales.ts similarity index 100% rename from client/js/locales.js rename to client/js/locales.ts diff --git a/client/js/requests/LoadingState.js b/client/js/requests/LoadingState.ts similarity index 100% rename from client/js/requests/LoadingState.js rename to client/js/requests/LoadingState.ts diff --git a/client/js/requests/common.js b/client/js/requests/common.ts similarity index 98% rename from client/js/requests/common.js rename to client/js/requests/common.ts index a89be52ac..9800633dd 100644 --- a/client/js/requests/common.js +++ b/client/js/requests/common.ts @@ -2,6 +2,8 @@ import { LoginError } from '../errors'; import * as ajax from '../helpers/ajax'; export class PasswordHashingError extends Error { + public name: any; + constructor(message) { super(message); this.name = 'PasswordHashingError'; diff --git a/client/js/requests/items.js b/client/js/requests/items.ts similarity index 100% rename from client/js/requests/items.js rename to client/js/requests/items.ts diff --git a/client/js/requests/sources.js b/client/js/requests/sources.ts similarity index 100% rename from client/js/requests/sources.js rename to client/js/requests/sources.ts diff --git a/client/js/requests/tags.js b/client/js/requests/tags.ts similarity index 100% rename from client/js/requests/tags.js rename to client/js/requests/tags.ts diff --git a/client/js/selfoss-base.js b/client/js/selfoss-base.ts similarity index 99% rename from client/js/selfoss-base.js rename to client/js/selfoss-base.ts index ad5b5d45a..9bfcea8e6 100644 --- a/client/js/selfoss-base.js +++ b/client/js/selfoss-base.ts @@ -243,7 +243,7 @@ const selfoss = { }); navigator.serviceWorker - .register(new URL('../selfoss-sw-offline.js', import.meta.url), { + .register(new URL('../selfoss-sw-offline.ts', import.meta.url), { type: 'module', }) .then((reg) => { diff --git a/client/js/selfoss-db-offline.js b/client/js/selfoss-db-offline.ts similarity index 99% rename from client/js/selfoss-db-offline.js rename to client/js/selfoss-db-offline.ts index 1fdc41a5c..2e8acec2a 100644 --- a/client/js/selfoss-db-offline.js +++ b/client/js/selfoss-db-offline.ts @@ -70,7 +70,8 @@ selfoss.dbOffline = { selfoss.db.lastUpdate = stamp.datetime; selfoss.dbOnline.firstSync = false; } else { - selfoss.dbOffline.shouldLoadEntriesOnline = true; + selfoss.dbOffline.shouldLoadEntriesOnline = + true; } }, ); diff --git a/client/js/selfoss-db-online.js b/client/js/selfoss-db-online.ts similarity index 100% rename from client/js/selfoss-db-online.js rename to client/js/selfoss-db-online.ts diff --git a/client/js/selfoss-db.js b/client/js/selfoss-db.ts similarity index 100% rename from client/js/selfoss-db.js rename to client/js/selfoss-db.ts diff --git a/client/js/sharers.jsx b/client/js/sharers.tsx similarity index 100% rename from client/js/sharers.jsx rename to client/js/sharers.tsx diff --git a/client/js/shortcuts.js b/client/js/shortcuts.ts similarity index 100% rename from client/js/shortcuts.js rename to client/js/shortcuts.ts diff --git a/client/js/templates/App.jsx b/client/js/templates/App.tsx similarity index 99% rename from client/js/templates/App.jsx rename to client/js/templates/App.tsx index be8973470..92fd8eed5 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.tsx @@ -423,6 +423,10 @@ PureApp.propTypes = { }; export class App extends React.Component { + public state: any; + public setState: any; + public props: any; + constructor(props) { super(props); this.state = { @@ -684,7 +688,7 @@ export class App extends React.Component { * @param ?array parameters * @return string */ - _(identifier, params) { + _(identifier, params?) { const fallbackLanguage = 'en'; const langKey = `lang_${identifier}`; diff --git a/client/js/templates/ColorChooser.jsx b/client/js/templates/ColorChooser.tsx similarity index 100% rename from client/js/templates/ColorChooser.jsx rename to client/js/templates/ColorChooser.tsx diff --git a/client/js/templates/EntriesPage.jsx b/client/js/templates/EntriesPage.tsx similarity index 99% rename from client/js/templates/EntriesPage.jsx rename to client/js/templates/EntriesPage.tsx index cdbbb657b..1006f676c 100644 --- a/client/js/templates/EntriesPage.jsx +++ b/client/js/templates/EntriesPage.tsx @@ -916,7 +916,7 @@ class StateHolder extends React.Component { * @param {number} id of entry to mark * @param {bool|'toggle'} true to mark read, false to mark unread */ - markEntryRead(id, markRead) { + markEntryRead(id, markRead?) { // only loggedin users if (!selfoss.isAllowedToWrite()) { console.log('User not allowed to mark an entry (un)read.'); @@ -1000,7 +1000,7 @@ class StateHolder extends React.Component { * @param {number} id of entry to mark * @param {bool|'toggle'} true to mark starred, false to mark unstarred */ - markEntryStarred(id, markStarred) { + markEntryStarred(id, markStarred?) { // only loggedin users if (!selfoss.isAllowedToWrite()) { console.log('User not allowed to (un)star an entry.'); diff --git a/client/js/templates/HashPassword.jsx b/client/js/templates/HashPassword.tsx similarity index 100% rename from client/js/templates/HashPassword.jsx rename to client/js/templates/HashPassword.tsx diff --git a/client/js/templates/Item.jsx b/client/js/templates/Item.tsx similarity index 100% rename from client/js/templates/Item.jsx rename to client/js/templates/Item.tsx diff --git a/client/js/templates/LoginForm.jsx b/client/js/templates/LoginForm.tsx similarity index 100% rename from client/js/templates/LoginForm.jsx rename to client/js/templates/LoginForm.tsx diff --git a/client/js/templates/NavFilters.jsx b/client/js/templates/NavFilters.tsx similarity index 100% rename from client/js/templates/NavFilters.jsx rename to client/js/templates/NavFilters.tsx diff --git a/client/js/templates/NavSearch.jsx b/client/js/templates/NavSearch.tsx similarity index 100% rename from client/js/templates/NavSearch.jsx rename to client/js/templates/NavSearch.tsx diff --git a/client/js/templates/NavSources.jsx b/client/js/templates/NavSources.tsx similarity index 100% rename from client/js/templates/NavSources.jsx rename to client/js/templates/NavSources.tsx diff --git a/client/js/templates/NavTags.jsx b/client/js/templates/NavTags.tsx similarity index 100% rename from client/js/templates/NavTags.jsx rename to client/js/templates/NavTags.tsx diff --git a/client/js/templates/NavToolBar.jsx b/client/js/templates/NavToolBar.tsx similarity index 100% rename from client/js/templates/NavToolBar.jsx rename to client/js/templates/NavToolBar.tsx diff --git a/client/js/templates/Navigation.jsx b/client/js/templates/Navigation.tsx similarity index 100% rename from client/js/templates/Navigation.jsx rename to client/js/templates/Navigation.tsx diff --git a/client/js/templates/OpmlImport.jsx b/client/js/templates/OpmlImport.tsx similarity index 100% rename from client/js/templates/OpmlImport.jsx rename to client/js/templates/OpmlImport.tsx diff --git a/client/js/templates/SearchList.jsx b/client/js/templates/SearchList.tsx similarity index 100% rename from client/js/templates/SearchList.jsx rename to client/js/templates/SearchList.tsx diff --git a/client/js/templates/Source.jsx b/client/js/templates/Source.tsx similarity index 100% rename from client/js/templates/Source.jsx rename to client/js/templates/Source.tsx diff --git a/client/js/templates/SourceParam.jsx b/client/js/templates/SourceParam.tsx similarity index 100% rename from client/js/templates/SourceParam.jsx rename to client/js/templates/SourceParam.tsx diff --git a/client/js/templates/SourcesPage.jsx b/client/js/templates/SourcesPage.tsx similarity index 99% rename from client/js/templates/SourcesPage.jsx rename to client/js/templates/SourcesPage.tsx index a2023023d..bde112949 100644 --- a/client/js/templates/SourcesPage.jsx +++ b/client/js/templates/SourcesPage.tsx @@ -170,7 +170,7 @@ export default function SourcesPage() { const _ = useContext(LocalizationContext); const [dirtySources, setDirtySources] = useState({}); - // eslint-disable-next-line no-unused-vars + // eslint-disable-next-line @typescript-eslint/no-unused-vars const isDirty = useMemo( () => Object.values(dirtySources).includes(true), [dirtySources], diff --git a/client/js/templates/Spinner.jsx b/client/js/templates/Spinner.tsx similarity index 100% rename from client/js/templates/Spinner.jsx rename to client/js/templates/Spinner.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 4d28e844f..56e4eca99 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -51,7 +51,9 @@ "prettier": "^3.0.0", "process": "^0.11.10", "stylelint": "^16.0.0", - "stylelint-config-standard-scss": "^14.0.0" + "stylelint-config-standard-scss": "^14.0.0", + "typescript": "^5.4.0", + "typescript-eslint": "^8.18.2" } }, "node_modules/@babel/code-frame": { @@ -2558,6 +2560,212 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", + "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/type-utils": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", + "integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz", + "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", + "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", + "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz", + "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", + "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", + "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -4319,6 +4527,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7418,6 +7633,19 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7535,6 +7763,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.2.tgz", + "integrity": "sha512-KuXezG6jHkvC3MvizeXgupZzaG5wjhU3yE8E7e6viOvAvD9xAWYp8/vy0WULTGe9DYDWcQu7aW03YIV3mSitrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.18.2", + "@typescript-eslint/parser": "8.18.2", + "@typescript-eslint/utils": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index f2be6ebda..0906c8ea8 100644 --- a/client/package.json +++ b/client/package.json @@ -47,7 +47,9 @@ "prettier": "^3.0.0", "process": "^0.11.10", "stylelint": "^16.0.0", - "stylelint-config-standard-scss": "^14.0.0" + "stylelint-config-standard-scss": "^14.0.0", + "typescript": "^5.4.0", + "typescript-eslint": "^8.18.2" }, "scripts": { "fix": "npm run fix:js && npm run fix:styles", @@ -60,7 +62,7 @@ "check": "npm run check:js && npm run check:styles", "check:js": "npm run check:js:prettify && npm run check:js:lint", "check:js:lint": "eslint", - "check:js:prettify": "prettier '**.{jsx,js}' --check", + "check:js:prettify": "prettier '**.{tsx,ts,js}' --check", "check:styles": "npm run check:styles:lint && npm run check:styles:prettify", "check:styles:lint": "stylelint styles/*.scss", "check:styles:prettify": "prettier styles/*.scss --check", diff --git a/client/selfoss-sw-offline.js b/client/selfoss-sw-offline.ts similarity index 100% rename from client/selfoss-sw-offline.js rename to client/selfoss-sw-offline.ts diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 000000000..9fe397d47 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,111 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "esnext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": false, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} From 773fbaf59c9170b28d4fa69385adb281f71cfe3a Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 29 Jul 2023 17:14:57 +0200 Subject: [PATCH 05/19] client: Turn Dexie into a typed object https://dexie.org/docs/Typescript-old --- client/js/model/OfflineDb.ts | 56 +++++++++++++++++++++++++++++++++ client/js/selfoss-db-offline.ts | 11 ++----- 2 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 client/js/model/OfflineDb.ts diff --git a/client/js/model/OfflineDb.ts b/client/js/model/OfflineDb.ts new file mode 100644 index 000000000..436fdcce3 --- /dev/null +++ b/client/js/model/OfflineDb.ts @@ -0,0 +1,56 @@ +import Dexie from 'dexie'; + +interface Entry { + id: number; + datetime: Date; +} + +interface Status { + id?: number; // Primary key. Optional (autoincremented). + entryId: string; + name: string; + value: string; + datetime: Date; +} + +interface Stamp { + name: string; + datetime: Date; +} + +interface Stat { + name: string; + value: number; +} + +interface Tag { + name: string; +} + +interface Source { + id: number; + first: string; +} + +export class OfflineDb extends Dexie { + // Declare implicit table properties. + // (Just to inform Typescript. Instanciated by Dexie in stores() method.) + entries!: Dexie.Table; + statusq!: Dexie.Table; + stamps!: Dexie.Table; + stats!: Dexie.Table; + tags!: Dexie.Table; + sources!: Dexie.Table; + + constructor() { + super('selfoss'); + this.version(1).stores({ + entries: '&id,*datetime,[datetime+id]', + statusq: '++id,*entryId', + stamps: '&name,datetime', + stats: '&name', + tags: '&name', + sources: '&id', + }); + } +} diff --git a/client/js/selfoss-db-offline.ts b/client/js/selfoss-db-offline.ts index 2e8acec2a..d55e15018 100644 --- a/client/js/selfoss-db-offline.ts +++ b/client/js/selfoss-db-offline.ts @@ -1,6 +1,7 @@ import selfoss from './selfoss-base'; import { OfflineStorageNotAvailableError } from './errors'; import Dexie from 'dexie'; +import { OfflineDb } from './model/OfflineDb'; import { FilterType } from './Filter'; const ENTRY_STATUS_NAMES = ['unread', 'starred']; @@ -40,15 +41,7 @@ selfoss.dbOffline = { } selfoss.db.broken = false; - selfoss.db.storage = new Dexie('selfoss'); - selfoss.db.storage.version(1).stores({ - entries: '&id,*datetime,[datetime+id]', - statusq: '++id,*entryId', - stamps: '&name,datetime', - stats: '&name', - tags: '&name', - sources: '&id', - }); + selfoss.db.storage = new OfflineDb(); selfoss.db.storage.on('populate', () => { selfoss.db.storage.stats.add({ name: 'unread', value: 0 }); From 9cc842d7341a3acc21a9955652bd8c79a11431a6 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 27 Jul 2023 01:06:28 +0200 Subject: [PATCH 06/19] client: Replace PropTypes with TS interfaces They have been deprecated since 2017. --- client/js/requests/LoadingState.ts | 13 +-- client/js/templates/App.tsx | 159 +++++++++++++++------------ client/js/templates/ColorChooser.tsx | 29 ++--- client/js/templates/EntriesPage.tsx | 110 +++++++++--------- client/js/templates/HashPassword.tsx | 13 ++- client/js/templates/Item.tsx | 67 ++++++----- client/js/templates/LoginForm.tsx | 13 ++- client/js/templates/NavFilters.tsx | 52 +++++---- client/js/templates/NavSearch.tsx | 15 +-- client/js/templates/NavSources.tsx | 55 ++++----- client/js/templates/NavTags.tsx | 28 ++--- client/js/templates/NavToolBar.tsx | 15 +-- client/js/templates/Navigation.tsx | 84 +++++++------- client/js/templates/OpmlImport.tsx | 13 ++- client/js/templates/SearchList.tsx | 17 +-- client/js/templates/Source.tsx | 118 ++++++++++---------- client/js/templates/SourceParam.tsx | 41 +++---- client/js/templates/Spinner.tsx | 24 ++-- client/package-lock.json | 11 -- client/package.json | 2 - 20 files changed, 459 insertions(+), 420 deletions(-) diff --git a/client/js/requests/LoadingState.ts b/client/js/requests/LoadingState.ts index e08d1ce23..5e6be37bc 100644 --- a/client/js/requests/LoadingState.ts +++ b/client/js/requests/LoadingState.ts @@ -1,10 +1,9 @@ /** * Object describing what state a request is in. - * @enum {string} */ -export const LoadingState = { - INITIAL: 'initial', - LOADING: 'loading', - SUCCESS: 'success', - FAILURE: 'failure', -}; +export enum LoadingState { + INITIAL = 'initial', + LOADING = 'loading', + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index 92fd8eed5..c622c1ede 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -1,6 +1,4 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import nullable from 'prop-types-nullable'; import { BrowserRouter as Router, Routes, @@ -17,7 +15,7 @@ import HashPassword from './HashPassword'; import OpmlImport from './OpmlImport'; import LoginForm from './LoginForm'; import SourcesPage from './SourcesPage'; -import EntriesPage from './EntriesPage'; +import EntriesPage, { StateHolder as EntriesPageStateful } from './EntriesPage'; import Navigation from './Navigation'; import SearchList from './SearchList'; import makeShortcuts from '../shortcuts'; @@ -31,6 +29,17 @@ import * as sourceRequests from '../requests/sources'; import locales from '../locales'; import { useEntriesParams } from '../helpers/uri'; +type MessageAction = { + label: string; + callback: (event: React.MouseEvent) => null; +}; + +type GlobalMessage = { + message: string; + actions: Array; + isError?: boolean; +}; + function handleNavToggle({ event, setNavExpanded }) { event.preventDefault(); @@ -44,12 +53,18 @@ function dismissMessage(event) { event.stopPropagation(); } +type MessageProps = { + message: GlobalMessage | null; +}; + /** * Global message bar for showing errors/information at the top of the page. * It watches globalMessage and updates/shows/hides itself as necessary * when the value changes. */ -function Message({ message }) { +function Message(props: MessageProps) { + const { message } = props; + // Whenever message changes, dismiss it after 15 seconds. useEffect(() => { if (message !== null) { @@ -80,17 +95,22 @@ function Message({ message }) { ) : null; } -Message.propTypes = { - message: nullable(PropTypes.object).isRequired, -}; - function NotFound() { const location = useLocation(); const _ = useContext(LocalizationContext); return

{_('error_invalid_subsection') + location.pathname}

; } -function CheckAuthorization({ isAllowed, returnLocation, _, children }) { +type CheckAuthorizationProps = { + isAllowed: boolean; + returnLocation?: string; + _: (translated: string, params?: { [index: string]: string }) => string; + children?: any; +}; + +function CheckAuthorization(props: CheckAuthorizationProps) { + const { isAllowed, returnLocation, _, children } = props; + const navigate = useNavigate(); if (!isAllowed) { const [preLink, inLink, postLink] = _('error_unauthorized').split( @@ -112,23 +132,27 @@ function CheckAuthorization({ isAllowed, returnLocation, _, children }) { } } -CheckAuthorization.propTypes = { - isAllowed: PropTypes.bool.isRequired, - returnLocation: PropTypes.string, - _: PropTypes.func.isRequired, - children: PropTypes.any, +type EntriesFilterProps = { + entriesRef: React.RefCallback; + setNavExpanded: React.Dispatch>; + configuration: object; + navSourcesExpanded: boolean; + unreadItemsCount: number; + setGlobalUnreadCount: React.Dispatch>; }; // Work around for regex patterns not being supported // https://github.com/remix-run/react-router/issues/8254 -function EntriesFilter({ - entriesRef, - setNavExpanded, - configuration, - navSourcesExpanded, - unreadItemsCount, - setGlobalUnreadCount, -}) { +function EntriesFilter(props: EntriesFilterProps) { + const { + entriesRef, + setNavExpanded, + configuration, + navSourcesExpanded, + unreadItemsCount, + setGlobalUnreadCount, + } = props; + const params = useEntriesParams(); if (params === null) { @@ -147,33 +171,45 @@ function EntriesFilter({ ); } -EntriesFilter.propTypes = { - entriesRef: PropTypes.func.isRequired, - configuration: PropTypes.object.isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setGlobalUnreadCount: PropTypes.func.isRequired, - unreadItemsCount: PropTypes.number.isRequired, +type PureAppProps = { + navSourcesExpanded: boolean; + setNavSourcesExpanded: React.Dispatch>; + offlineState: boolean; + allItemsCount: number; + allItemsOfflineCount: number; + unreadItemsCount: number; + unreadItemsOfflineCount: number; + starredItemsCount: number; + starredItemsOfflineCount: number; + globalMessage: object | null; + sourcesState: LoadingState; + setSourcesState: React.Dispatch>; + sources: Array; + setSources: React.Dispatch>>; + tags: Array; + reloadAll: () => void; }; -function PureApp({ - navSourcesExpanded, - setNavSourcesExpanded, - offlineState, - allItemsCount, - allItemsOfflineCount, - unreadItemsCount, - unreadItemsOfflineCount, - starredItemsCount, - starredItemsOfflineCount, - globalMessage, - sourcesState, - setSourcesState, - sources, - setSources, - tags, - reloadAll, -}) { +function PureApp(props: PureAppProps) { + const { + navSourcesExpanded, + setNavSourcesExpanded, + offlineState, + allItemsCount, + allItemsOfflineCount, + unreadItemsCount, + unreadItemsOfflineCount, + starredItemsCount, + starredItemsOfflineCount, + globalMessage, + sourcesState, + setSourcesState, + sources, + setSources, + tags, + reloadAll, + } = props; + const [navExpanded, setNavExpanded] = useState(false); const smartphone = useIsSmartphone(); const offlineEnabled = useListenableValue(selfoss.db.enableOffline); @@ -403,31 +439,16 @@ function PureApp({ ); } -PureApp.propTypes = { - navSourcesExpanded: PropTypes.bool.isRequired, - setNavSourcesExpanded: PropTypes.func.isRequired, - offlineState: PropTypes.bool.isRequired, - allItemsCount: PropTypes.number.isRequired, - allItemsOfflineCount: PropTypes.number.isRequired, - unreadItemsCount: PropTypes.number.isRequired, - unreadItemsOfflineCount: PropTypes.number.isRequired, - starredItemsCount: PropTypes.number.isRequired, - starredItemsOfflineCount: PropTypes.number.isRequired, - globalMessage: nullable(PropTypes.object).isRequired, - sourcesState: PropTypes.oneOf(Object.values(LoadingState)).isRequired, - setSourcesState: PropTypes.func.isRequired, - sources: PropTypes.arrayOf(PropTypes.object).isRequired, - setSources: PropTypes.func.isRequired, - tags: PropTypes.arrayOf(PropTypes.object).isRequired, - reloadAll: PropTypes.func.isRequired, +type AppProps = { + configuration: object; }; export class App extends React.Component { public state: any; - public setState: any; - public props: any; + public setState: React.Dispatch>; + public props: AppProps; - constructor(props) { + constructor(props: AppProps) { super(props); this.state = { /** @@ -846,10 +867,6 @@ export class App extends React.Component { } } -App.propTypes = { - configuration: PropTypes.object.isRequired, -}; - /** * Creates the selfoss single-page application * with the required contexts. diff --git a/client/js/templates/ColorChooser.tsx b/client/js/templates/ColorChooser.tsx index 07afa26ad..b0714b7d7 100644 --- a/client/js/templates/ColorChooser.tsx +++ b/client/js/templates/ColorChooser.tsx @@ -1,5 +1,4 @@ import React, { useContext, useMemo } from 'react'; -import PropTypes from 'prop-types'; import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { colorByBrightness } from '../helpers/color'; @@ -71,7 +70,14 @@ const palette = [ '#340096', ]; -function ColorButton({ tag, color }) { +type ColorButtonProps = { + tag: object; + color: string; +}; + +function ColorButton(props: ColorButtonProps) { + const { tag, color } = props; + const style = useMemo( () => ({ backgroundColor: color, @@ -89,18 +95,20 @@ function ColorButton({ tag, color }) { ); } -ColorButton.propTypes = { - tag: PropTypes.object.isRequired, - color: PropTypes.string.isRequired, -}; - const preventDefault = (event) => { event.preventDefault(); // Prevent closing navigation on mobile. event.stopPropagation(); }; -export default function ColorChooser({ tag, onChange }) { +type ColorChooserProps = { + tag: object; + onChange: ({ value: string }) => void; +}; + +export default function ColorChooser(props: ColorChooserProps) { + const { tag, onChange } = props; + const style = useMemo(() => ({ backgroundColor: tag.color }), [tag.color]); const _ = useContext(LocalizationContext); @@ -134,8 +142,3 @@ export default function ColorChooser({ tag, onChange }) { ); } - -ColorChooser.propTypes = { - tag: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/EntriesPage.tsx b/client/js/templates/EntriesPage.tsx index 1006f676c..4c11fb42c 100644 --- a/client/js/templates/EntriesPage.tsx +++ b/client/js/templates/EntriesPage.tsx @@ -6,11 +6,9 @@ import React, { useState, forwardRef, } from 'react'; -import PropTypes from 'prop-types'; -import { Link, useLocation, useParams } from 'react-router'; +import { Link, NavigateFunction, useLocation, useParams } from 'react-router'; import { useOnline } from 'rooks'; import { useStateWithDeps } from 'use-state-with-deps'; -import nullable from 'prop-types-nullable'; import Item from './Item'; import { FilterType } from '../Filter'; import * as itemsRequests from '../requests/items'; @@ -214,19 +212,35 @@ function handleRefreshSource({ }); } -export function EntriesPage({ - entries, - hasMore, - loadingState, - setLoadingState, - selectedEntry, - expandedEntries, - setNavExpanded, - navSourcesExpanded, - reload, - setGlobalUnreadCount, - unreadItemsCount, -}) { +type EntriesPageProps = { + entries: Array; + hasMore: boolean; + loadingState: LoadingState; + setLoadingState: React.Dispatch>; + selectedEntry: number | null; + expandedEntries: { [index: string]: boolean }; + setNavExpanded: React.Dispatch>; + navSourcesExpanded: boolean; + reload: () => void; + setGlobalUnreadCount: React.Dispatch>; + unreadItemsCount: number; +}; + +export function EntriesPage(props: EntriesPageProps) { + const { + entries, + hasMore, + loadingState, + setLoadingState, + selectedEntry, + expandedEntries, + setNavExpanded, + navSourcesExpanded, + reload, + setGlobalUnreadCount, + unreadItemsCount, + } = props; + const allowedToUpdate = useAllowedToUpdate(); const allowedToWrite = useAllowedToWrite(); const configuration = useContext(ConfigurationContext); @@ -520,20 +534,6 @@ export function EntriesPage({ ); } -EntriesPage.propTypes = { - entries: PropTypes.array.isRequired, - hasMore: PropTypes.bool.isRequired, - loadingState: PropTypes.oneOf(Object.values(LoadingState)).isRequired, - setLoadingState: PropTypes.func.isRequired, - selectedEntry: nullable(PropTypes.number).isRequired, - expandedEntries: PropTypes.objectOf(PropTypes.bool).isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - reload: PropTypes.func.isRequired, - setGlobalUnreadCount: PropTypes.func.isRequired, - unreadItemsCount: PropTypes.number.isRequired, -}; - const initialState = { entries: [], hasMore: false, @@ -547,8 +547,22 @@ const initialState = { loadingState: LoadingState.INITIAL, }; -class StateHolder extends React.Component { - constructor(props) { +type StateHolderProps = { + configuration: object; + location: object; + navigate: NavigateFunction; + params: object; + setNavExpanded: React.Dispatch>; + navSourcesExpanded: boolean; + setGlobalUnreadCount: React.Dispatch>; + unreadItemsCount: number; +}; + +export class StateHolder extends React.Component { + public state: any; + public props: StateHolderProps; + + constructor(props: StateHolderProps) { super(props); this.state = initialState; @@ -1238,27 +1252,25 @@ class StateHolder extends React.Component { } } -StateHolder.propTypes = { - configuration: PropTypes.object.isRequired, - location: PropTypes.object.isRequired, - navigate: PropTypes.func.isRequired, - params: PropTypes.object.isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setGlobalUnreadCount: PropTypes.func.isRequired, - unreadItemsCount: PropTypes.number.isRequired, +type StateHolderOuterProps = { + configuration: object; + setNavExpanded: React.Dispatch>; + navSourcesExpanded: boolean; + setGlobalUnreadCount: React.Dispatch>; + unreadItemsCount: number; }; const StateHolderOuter = forwardRef(function StateHolderOuter( - { + props: StateHolderOuterProps, + ref, +) { + const { configuration, setNavExpanded, navSourcesExpanded, setGlobalUnreadCount, unreadItemsCount, - }, - ref, -) { + } = props; const location = useLocation(); const navigate = useNavigate(); const params = useParams(); @@ -1278,12 +1290,4 @@ const StateHolderOuter = forwardRef(function StateHolderOuter( ); }); -StateHolderOuter.propTypes = { - configuration: PropTypes.object.isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setGlobalUnreadCount: PropTypes.func.isRequired, - unreadItemsCount: PropTypes.number.isRequired, -}; - export default StateHolderOuter; diff --git a/client/js/templates/HashPassword.tsx b/client/js/templates/HashPassword.tsx index 6b044c56d..f662b3521 100644 --- a/client/js/templates/HashPassword.tsx +++ b/client/js/templates/HashPassword.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { useInput } from 'rooks'; @@ -6,7 +5,13 @@ import { LoadingState } from '../requests/LoadingState'; import { HttpError } from '../errors'; import { hashPassword } from '../requests/common'; -export default function HashPassword({ setTitle }) { +type HashPasswordProps = { + setTitle: (title: string | null) => void; +}; + +export default function HashPassword(props: HashPasswordProps) { + const { setTitle } = props; + const [state, setState] = useState(LoadingState.INITIAL); const [hashedPassword, setHashedPassword] = useState(''); const [error, setError] = useState(null); @@ -106,7 +111,3 @@ export default function HashPassword({ setTitle }) { ); } - -HashPassword.propTypes = { - setTitle: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/Item.tsx b/client/js/templates/Item.tsx index 11a674cce..84a0629a2 100644 --- a/client/js/templates/Item.tsx +++ b/client/js/templates/Item.tsx @@ -6,7 +6,6 @@ import React, { useRef, useState, } from 'react'; -import PropTypes from 'prop-types'; import { Link, useNavigate, useLocation } from 'react-router'; import { usePreviousImmediate } from 'rooks'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -155,7 +154,27 @@ function openNext(event) { }); } -function ShareButton({ label, icon, item, action, showLabel = true }) { +type ShareAction = ({ + id, + url, + title, +}: { + id: string; + url: string; + title: string; +}) => void; + +type ShareButtonProps = { + label: string; + icon: string | HTMLElement; + item: object; + action: ShareAction; + showLabel?: boolean; +}; + +function ShareButton(props: ShareButtonProps) { + const { label, icon, item, action, showLabel = true } = props; + const shareOnClick = useCallback( (event) => { event.preventDefault(); @@ -184,15 +203,14 @@ function ShareButton({ label, icon, item, action, showLabel = true }) { ); } -ShareButton.propTypes = { - label: PropTypes.string.isRequired, - icon: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired, - item: PropTypes.object.isRequired, - action: PropTypes.func.isRequired, - showLabel: PropTypes.bool, +type ItemTagProps = { + tag: string; + color: object; }; -function ItemTag({ tag, color }) { +function ItemTag(props: ItemTagProps) { + const { tag, color } = props; + const style = useMemo( () => ({ color: color.foreColor, backgroundColor: color.backColor }), [color], @@ -222,11 +240,6 @@ function ItemTag({ tag, color }) { ); } -ItemTag.propTypes = { - tag: PropTypes.string.isRequired, - color: PropTypes.object.isRequired, -}; - /** * Converts Date to a relative string. * When the date is too old, null is returned instead. @@ -249,13 +262,17 @@ function datetimeRelative(currentTime, datetime) { } } -export default function Item({ - currentTime, - item, - selected, - expanded, - setNavExpanded, -}) { +type ItemProps = { + currentTime: Date; + item: object; + selected: boolean; + expanded: boolean; + setNavExpanded: React.Dispatch>; +}; + +export default function Item(props: ItemProps) { + const { currentTime, item, selected, expanded, setNavExpanded } = props; + const { title, author, sourcetitle } = item; const [fullScreenTrap, setFullScreenTrap] = useState(null); @@ -758,11 +775,3 @@ export default function Item({ ); } - -Item.propTypes = { - currentTime: PropTypes.instanceOf(Date).isRequired, - item: PropTypes.object.isRequired, - selected: PropTypes.bool.isRequired, - expanded: PropTypes.bool.isRequired, - setNavExpanded: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/LoginForm.tsx b/client/js/templates/LoginForm.tsx index 9aa5139f8..79b120212 100644 --- a/client/js/templates/LoginForm.tsx +++ b/client/js/templates/LoginForm.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useContext, useState } from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { SpinnerBig } from './Spinner'; import { useLocation, useNavigate } from 'react-router'; @@ -48,7 +47,13 @@ function handleLogIn({ }); } -export default function LoginForm({ offlineEnabled }) { +type LoginFormProps = { + offlineEnabled: boolean; +}; + +export default function LoginForm(props: LoginFormProps) { + const { offlineEnabled } = props; + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); @@ -168,7 +173,3 @@ export default function LoginForm({ offlineEnabled }) { ); } - -LoginForm.propTypes = { - offlineEnabled: PropTypes.bool.isRequired, -}; diff --git a/client/js/templates/NavFilters.tsx b/client/js/templates/NavFilters.tsx index 554920466..562022b4b 100644 --- a/client/js/templates/NavFilters.tsx +++ b/client/js/templates/NavFilters.tsx @@ -1,25 +1,40 @@ import React, { useCallback, useContext, useState, useMemo } from 'react'; -import PropTypes from 'prop-types'; -import { useEntriesParams } from '../helpers/uri'; import { Link, useLocation } from 'react-router'; import classNames from 'classnames'; import { FilterType } from '../Filter'; -import { makeEntriesLinkLocation, useForceReload } from '../helpers/uri'; +import { + makeEntriesLinkLocation, + useEntriesParams, + useForceReload, +} from '../helpers/uri'; import { Collapse } from '@kunukn/react-collapse'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as icons from '../icons'; import { LocalizationContext } from '../helpers/i18n'; -export default function NavFilters({ - setNavExpanded, - offlineState, - allItemsCount, - allItemsOfflineCount, - unreadItemsCount, - unreadItemsOfflineCount, - starredItemsCount, - starredItemsOfflineCount, -}) { +type NavFiltersProps = { + setNavExpanded: React.Dispatch>; + offlineState: boolean; + allItemsCount: number; + allItemsOfflineCount: number; + unreadItemsCount: number; + unreadItemsOfflineCount: number; + starredItemsCount: number; + starredItemsOfflineCount: number; +}; + +export default function NavFilters(props: NavFiltersProps) { + const { + setNavExpanded, + offlineState, + allItemsCount, + allItemsOfflineCount, + unreadItemsCount, + unreadItemsOfflineCount, + starredItemsCount, + starredItemsOfflineCount, + } = props; + const [expanded, setExpanded] = useState(true); const params = useEntriesParams(); @@ -212,14 +227,3 @@ export default function NavFilters({ ); } - -NavFilters.propTypes = { - setNavExpanded: PropTypes.func.isRequired, - offlineState: PropTypes.bool.isRequired, - allItemsCount: PropTypes.number.isRequired, - allItemsOfflineCount: PropTypes.number.isRequired, - unreadItemsCount: PropTypes.number.isRequired, - unreadItemsOfflineCount: PropTypes.number.isRequired, - starredItemsCount: PropTypes.number.isRequired, - starredItemsOfflineCount: PropTypes.number.isRequired, -}; diff --git a/client/js/templates/NavSearch.tsx b/client/js/templates/NavSearch.tsx index 9d2b1e21b..542c32280 100644 --- a/client/js/templates/NavSearch.tsx +++ b/client/js/templates/NavSearch.tsx @@ -5,7 +5,6 @@ import React, { useRef, useState, } from 'react'; -import PropTypes from 'prop-types'; import { useLocation, useNavigate } from 'react-router'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -62,7 +61,14 @@ function handleRemove({ setActive, searchField, navigate, location }) { navigate(makeEntriesLink(location, { search: '', id: null })); } -export default function NavSearch({ setNavExpanded, offlineState }) { +type NavSearchProps = { + setNavExpanded: React.Dispatch>; + offlineState: boolean; +}; + +export default function NavSearch(props: NavSearchProps) { + const { setNavExpanded, offlineState } = props; + const [active, setActive] = useState(false); const searchField = useRef(null); @@ -162,8 +168,3 @@ export default function NavSearch({ setNavExpanded, offlineState }) { ); } - -NavSearch.propTypes = { - setNavExpanded: PropTypes.func.isRequired, - offlineState: PropTypes.bool.isRequired, -}; diff --git a/client/js/templates/NavSources.tsx b/client/js/templates/NavSources.tsx index c4a144811..6ac29979f 100644 --- a/client/js/templates/NavSources.tsx +++ b/client/js/templates/NavSources.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo, useContext, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Link, useLocation } from 'react-router'; +import { Link } from 'react-router'; import { usePreviousImmediate } from 'rooks'; import classNames from 'classnames'; import { unescape } from 'html-escaper'; @@ -49,7 +48,15 @@ function handleTitleClick({ }); } -function Source({ source, active, collapseNav }) { +type SourceProps = { + source: object; + active: boolean; + collapseNav: () => void; +}; + +function Source(props: SourceProps) { + const { source, active, collapseNav } = props; + const location = useLocation(); const link = useMemo( () => @@ -78,21 +85,27 @@ function Source({ source, active, collapseNav }) { ); } -Source.propTypes = { - source: PropTypes.object.isRequired, - active: PropTypes.bool.isRequired, - collapseNav: PropTypes.func.isRequired, +type NavSourcesProps = { + setNavExpanded: React.Dispatch>; + navSourcesExpanded: boolean; + setNavSourcesExpanded: React.Dispatch>; + sourcesState: LoadingState; + setSourcesState: React.Dispatch>; + sources: Array; + setSources: React.Dispatch>>; }; -export default function NavSources({ - setNavExpanded, - navSourcesExpanded, - setNavSourcesExpanded, - sourcesState, - setSourcesState, - sources, - setSources, -}) { +export default function NavSources(props: NavSourcesProps) { + const { + setNavExpanded, + navSourcesExpanded, + setNavSourcesExpanded, + sourcesState, + setSourcesState, + sources, + setSources, + } = props; + const reallyExpanded = navSourcesExpanded && sourcesState === LoadingState.SUCCESS; @@ -173,13 +186,3 @@ export default function NavSources({ ); } - -NavSources.propTypes = { - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setNavSourcesExpanded: PropTypes.func.isRequired, - sourcesState: PropTypes.oneOf(Object.values(LoadingState)).isRequired, - setSourcesState: PropTypes.func.isRequired, - sources: PropTypes.arrayOf(PropTypes.object).isRequired, - setSources: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/NavTags.tsx b/client/js/templates/NavTags.tsx index e552b3066..7113592a1 100644 --- a/client/js/templates/NavTags.tsx +++ b/client/js/templates/NavTags.tsx @@ -1,6 +1,4 @@ import React, { useContext, useMemo, useCallback, useState } from 'react'; -import PropTypes from 'prop-types'; -import nullable from 'prop-types-nullable'; import { Link, useLocation } from 'react-router'; import classNames from 'classnames'; import { unescape } from 'html-escaper'; @@ -16,7 +14,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as icons from '../icons'; import { LocalizationContext } from '../helpers/i18n'; -function Tag({ tag, active, collapseNav }) { +type TagProps = { + tag: object | null; + active: boolean; + collapseNav: () => void; +}; + +function Tag(props: TagProps) { + const { tag, active, collapseNav } = props; + const _ = useContext(LocalizationContext); const tagName = tag !== null ? tag.tag : null; @@ -74,13 +80,14 @@ function Tag({ tag, active, collapseNav }) { ); } -Tag.propTypes = { - tag: nullable(PropTypes.object).isRequired, - active: PropTypes.bool.isRequired, - collapseNav: PropTypes.func.isRequired, +type NavTagsProps = { + setNavExpanded: React.Dispatch>; + tags: Array; }; -export default function NavTags({ setNavExpanded, tags }) { +export default function NavTags(props: NavTagsProps) { + const { setNavExpanded, tags } = props; + const [expanded, setExpanded] = useState(true); const params = useEntriesParams(); @@ -148,8 +155,3 @@ export default function NavTags({ setNavExpanded, tags }) { ); } - -NavTags.propTypes = { - setNavExpanded: PropTypes.func.isRequired, - tags: PropTypes.arrayOf(PropTypes.object).isRequired, -}; diff --git a/client/js/templates/NavToolBar.tsx b/client/js/templates/NavToolBar.tsx index fc741d730..76ba3095c 100644 --- a/client/js/templates/NavToolBar.tsx +++ b/client/js/templates/NavToolBar.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useContext, useState } from 'react'; -import PropTypes from 'prop-types'; import { Link } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as icons from '../icons'; @@ -31,7 +30,14 @@ function handleLogOut({ setNavExpanded }) { setNavExpanded(false); } -export default function NavToolBar({ reloadAll, setNavExpanded }) { +type NavToolBarProps = { + reloadAll: () => void; + setNavExpanded: React.Dispatch>; +}; + +export default function NavToolBar(props: NavToolBarProps) { + const { reloadAll, setNavExpanded } = props; + const [reloading, setReloading] = useState(false); const forceReload = useForceReload(); @@ -113,8 +119,3 @@ export default function NavToolBar({ reloadAll, setNavExpanded }) { ); } - -NavToolBar.propTypes = { - reloadAll: PropTypes.func.isRequired, - setNavExpanded: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/Navigation.tsx b/client/js/templates/Navigation.tsx index d53f5a7a0..86696d36d 100644 --- a/client/js/templates/Navigation.tsx +++ b/client/js/templates/Navigation.tsx @@ -1,8 +1,6 @@ import React, { useContext } from 'react'; -import PropTypes from 'prop-types'; -import nullable from 'prop-types-nullable'; import classNames from 'classnames'; -import EntriesPage from './EntriesPage'; +import { StateHolder as EntriesPage } from './EntriesPage'; import NavFilters from './NavFilters'; import NavSources from './NavSources'; import NavSearch from './NavSearch'; @@ -14,25 +12,47 @@ import { LoadingState } from '../requests/LoadingState'; import { useAllowedToWrite } from '../helpers/authorizations'; import { LocalizationContext } from '../helpers/i18n'; -export default function Navigation({ - entriesPage, - setNavExpanded, - navSourcesExpanded, - setNavSourcesExpanded, - offlineState, - allItemsCount, - allItemsOfflineCount, - unreadItemsCount, - unreadItemsOfflineCount, - starredItemsCount, - starredItemsOfflineCount, - sourcesState, - setSourcesState, - sources, - setSources, - tags, - reloadAll, -}) { +type NavigationProps = { + entriesPage: EntriesPage | null; + setNavExpanded: React.Dispatch>; + navSourcesExpanded: boolean; + setNavSourcesExpanded: React.Dispatch>; + offlineState: boolean; + allItemsCount: number; + allItemsOfflineCount: number; + unreadItemsCount: number; + unreadItemsOfflineCount: number; + starredItemsCount: number; + starredItemsOfflineCount: number; + sourcesState: LoadingState; + setSourcesState: React.Dispatch>; + sources: Array; + setSources: React.Dispatch>>; + tags: Array; + reloadAll: React.Dispatch>>; +}; + +export default function Navigation(props: NavigationProps) { + const { + entriesPage, + setNavExpanded, + navSourcesExpanded, + setNavSourcesExpanded, + offlineState, + allItemsCount, + allItemsOfflineCount, + unreadItemsCount, + unreadItemsOfflineCount, + starredItemsCount, + starredItemsOfflineCount, + sourcesState, + setSourcesState, + sources, + setSources, + tags, + reloadAll, + } = props; + const _ = useContext(LocalizationContext); const canWrite = useAllowedToWrite(); @@ -116,23 +136,3 @@ export default function Navigation({ ); } - -Navigation.propTypes = { - entriesPage: nullable(PropTypes.instanceOf(EntriesPage)).isRequired, - setNavExpanded: PropTypes.func.isRequired, - navSourcesExpanded: PropTypes.bool.isRequired, - setNavSourcesExpanded: PropTypes.func.isRequired, - offlineState: PropTypes.bool.isRequired, - allItemsCount: PropTypes.number.isRequired, - allItemsOfflineCount: PropTypes.number.isRequired, - unreadItemsCount: PropTypes.number.isRequired, - unreadItemsOfflineCount: PropTypes.number.isRequired, - starredItemsCount: PropTypes.number.isRequired, - starredItemsOfflineCount: PropTypes.number.isRequired, - sourcesState: PropTypes.oneOf(Object.values(LoadingState)).isRequired, - setSourcesState: PropTypes.func.isRequired, - sources: PropTypes.arrayOf(PropTypes.object).isRequired, - setSources: PropTypes.func.isRequired, - tags: PropTypes.arrayOf(PropTypes.object).isRequired, - reloadAll: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/OpmlImport.tsx b/client/js/templates/OpmlImport.tsx index 343c7d77a..569dd5ba0 100644 --- a/client/js/templates/OpmlImport.tsx +++ b/client/js/templates/OpmlImport.tsx @@ -1,12 +1,17 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; import { useOnline } from 'rooks'; import { Link, useNavigate } from 'react-router'; import { LoadingState } from '../requests/LoadingState'; import { HttpError, UnexpectedStateError } from '../errors'; import { importOpml } from '../requests/common'; -export default function OpmlImport({ setTitle }) { +type OpmlImportProps = { + setTitle: (title: string | null) => void; +}; + +export default function OpmlImport(props: OpmlImportProps) { + const { setTitle } = props; + const [state, setState] = useState(LoadingState.INITIAL); const [message, setMessage] = useState(null); const fileEntry = useRef(); @@ -162,7 +167,3 @@ export default function OpmlImport({ setTitle }) { ); } - -OpmlImport.propTypes = { - setTitle: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/SearchList.tsx b/client/js/templates/SearchList.tsx index 31d86f6a5..bd72764fa 100644 --- a/client/js/templates/SearchList.tsx +++ b/client/js/templates/SearchList.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useMemo } from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useLocation, useNavigate } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -49,7 +48,15 @@ function handleRemove({ index, location, navigate, regexSearch }) { navigate(makeEntriesLink(location, { search: newterm, id: null })); } -function SearchWord({ regexSearch, index, item }) { +type SearchWordProps = { + regexSearch: boolean; + index: number; + item: string; +}; + +function SearchWord(props: SearchWordProps) { + const { regexSearch, index, item } = props; + const location = useLocation(); const navigate = useNavigate(); @@ -68,12 +75,6 @@ function SearchWord({ regexSearch, index, item }) { ); } -SearchWord.propTypes = { - regexSearch: PropTypes.bool.isRequired, - index: PropTypes.number.isRequired, - item: PropTypes.string.isRequired, -}; - /** * Component for showing list of search terms at the top of the page. */ diff --git a/client/js/templates/Source.tsx b/client/js/templates/Source.tsx index 3fa153781..59b9e4f54 100644 --- a/client/js/templates/Source.tsx +++ b/client/js/templates/Source.tsx @@ -4,8 +4,6 @@ import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; import { useNavigate, useLocation } from 'react-router'; import { fadeOut } from '@siteparts/show-hide-effects'; import { makeEntriesLinkLocation } from '../helpers/uri'; -import PropTypes from 'prop-types'; -import nullable from 'prop-types-nullable'; import { unescape } from 'html-escaper'; import classNames from 'classnames'; import { pick } from 'lodash-es'; @@ -253,26 +251,51 @@ function daysAgo(date) { return Math.floor((today - old) / MS_PER_DAY); } -function SourceEditForm({ - source, - sourceElem, - sourceError, - setSources, - spouts, - setSpouts, - setEditedSource, - sourceActionLoading, - setSourceActionLoading, - sourceParamsLoading, - setSourceParamsLoading, - sourceParamsError, - setSourceParamsError, - setJustSavedTimeout, - sourceErrors, - setSourceErrors, - dirty, - setDirty, -}) { +type SourceEditFormProps = { + source: object; + sourceElem: object; + sourceError?: string; + setSources: React.Dispatch>>; + spouts: object; + setSpouts: React.Dispatch>>; + setEditedSource: React.Dispatch>; + sourceActionLoading: boolean; + setSourceActionLoading: React.Dispatch>; + sourceParamsLoading: boolean; + setSourceParamsLoading: React.Dispatch>; + sourceParamsError: string | null; + setSourceParamsError: React.Dispatch>; + setJustSavedTimeout: React.Dispatch>; + sourceErrors: { [index: string]: string }; + setSourceErrors: React.Dispatch< + React.SetStateAction<{ [index: string]: string }> + >; + dirty: boolean; + setDirty: React.Dispatch>; +}; + +function SourceEditForm(props: SourceEditFormProps) { + const { + source, + sourceElem, + sourceError, + setSources, + spouts, + setSpouts, + setEditedSource, + sourceActionLoading, + setSourceActionLoading, + sourceParamsLoading, + setSourceParamsLoading, + sourceParamsError, + setSourceParamsError, + setJustSavedTimeout, + sourceErrors, + setSourceErrors, + dirty, + setDirty, + } = props; + const sourceId = source.id; const updateEditedSource = useCallback( (changes) => { @@ -535,35 +558,19 @@ function SourceEditForm({ ); } -SourceEditForm.propTypes = { - source: PropTypes.object.isRequired, - sourceElem: PropTypes.object.isRequired, - sourceError: PropTypes.string, - setSources: PropTypes.func.isRequired, - spouts: PropTypes.object.isRequired, - setSpouts: PropTypes.func.isRequired, - setEditedSource: PropTypes.func.isRequired, - sourceActionLoading: PropTypes.bool.isRequired, - setSourceActionLoading: PropTypes.func.isRequired, - sourceParamsLoading: PropTypes.bool.isRequired, - setSourceParamsLoading: PropTypes.func.isRequired, - sourceParamsError: nullable(PropTypes.string).isRequired, - setSourceParamsError: PropTypes.func.isRequired, - setJustSavedTimeout: PropTypes.func.isRequired, - sourceErrors: PropTypes.objectOf(PropTypes.string).isRequired, - setSourceErrors: PropTypes.func.isRequired, - dirty: PropTypes.bool.isRequired, - setDirty: PropTypes.func.isRequired, +type SourceProps = { + source: object; + setSources: React.Dispatch>>; + spouts: object; + setSpouts: React.Dispatch>; + dirty: boolean; + setDirtySources: React.Dispatch>; }; -export default function Source({ - source, - setSources, - spouts, - setSpouts, - dirty, - setDirtySources, -}) { +export default function Source(props: SourceProps) { + const { source, setSources, spouts, setSpouts, dirty, setDirtySources } = + props; + const isNew = !source.title; const classes = { source: true, @@ -708,7 +715,9 @@ export default function Source({
{source.lastentry - ? ` • ${_('source_last_post')} ${_('days', [daysAgo(new Date(source.lastentry * 1000))])}` + ? ` • ${_('source_last_post')} ${_('days', [ + daysAgo(new Date(source.lastentry * 1000)), + ])}` : null}
{/* edit */} @@ -739,12 +748,3 @@ export default function Source({ ); } - -Source.propTypes = { - source: PropTypes.object.isRequired, - setSources: PropTypes.func.isRequired, - spouts: PropTypes.object.isRequired, - setSpouts: PropTypes.func.isRequired, - dirty: PropTypes.bool.isRequired, - setDirtySources: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/SourceParam.tsx b/client/js/templates/SourceParam.tsx index 8a529f432..4f3f2655c 100644 --- a/client/js/templates/SourceParam.tsx +++ b/client/js/templates/SourceParam.tsx @@ -1,16 +1,27 @@ import React, { useCallback, useContext } from 'react'; -import PropTypes from 'prop-types'; import { LocalizationContext } from '../helpers/i18n'; -export default function SourceParam({ - spoutParamName, - spoutParam, - params = {}, - sourceErrors, - sourceId, - setEditedSource, - setDirty, -}) { +type SourceParamProps = { + spoutParamName: string; + spoutParam: object; + params: object; + sourceErrors: { [index: string]: string }; + sourceId: number; + setEditedSource: React.Dispatch>; + setDirty: React.Dispatch>; +}; + +export default function SourceParam(props: SourceParamProps) { + const { + spoutParamName, + spoutParam, + params = {}, + sourceErrors, + sourceId, + setEditedSource, + setDirty, + } = props; + const updateSourceParam = useCallback( (event) => { setDirty(true); @@ -109,13 +120,3 @@ export default function SourceParam({ ); } - -SourceParam.propTypes = { - spoutParamName: PropTypes.string.isRequired, - spoutParam: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - sourceErrors: PropTypes.objectOf(PropTypes.string).isRequired, - sourceId: PropTypes.number.isRequired, - setEditedSource: PropTypes.func.isRequired, - setDirty: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/Spinner.tsx b/client/js/templates/Spinner.tsx index c339aa07f..1d9a168cb 100644 --- a/client/js/templates/Spinner.tsx +++ b/client/js/templates/Spinner.tsx @@ -1,9 +1,16 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as icons from '../icons'; +import { SizeProp } from '@fortawesome/fontawesome-svg-core'; + +type SpinnerProps = { + label: string; + size?: SizeProp; +}; + +export function Spinner(props: SpinnerProps) { + const { label, size } = props; -export function Spinner({ label, size }) { return ( <> ); } - -SpinnerBig.propTypes = { - label: PropTypes.string.isRequired, -}; diff --git a/client/package-lock.json b/client/package-lock.json index 56e4eca99..9d3d47884 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -21,8 +21,6 @@ "form-urlencoded": "^6.0.0", "html-escaper": "^3.0.0", "lodash-es": "^4.17.21", - "prop-types": "^15.7.2", - "prop-types-nullable": "^1.0.1", "ramda": "^0.30.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -6507,15 +6505,6 @@ "react-is": "^16.13.1" } }, - "node_modules/prop-types-nullable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prop-types-nullable/-/prop-types-nullable-1.0.1.tgz", - "integrity": "sha512-ks2Z+cf/d4dPGVSDVY9pUOaNxoTZ1AyNOdzvNBmUZJoqsRIhlFCI/eY5wywtIlkGg96OvrEZBCB0TqnRgBzopA==", - "license": "MIT", - "peerDependencies": { - "prop-types": "*" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/client/package.json b/client/package.json index 0906c8ea8..ca89156f6 100644 --- a/client/package.json +++ b/client/package.json @@ -17,8 +17,6 @@ "form-urlencoded": "^6.0.0", "html-escaper": "^3.0.0", "lodash-es": "^4.17.21", - "prop-types": "^15.7.2", - "prop-types-nullable": "^1.0.1", "ramda": "^0.30.0", "react": "^18.2.0", "react-dom": "^18.2.0", From 46ff68140c74e1e2d9b13387421ad074fb907237 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 29 Jul 2023 01:23:26 +0200 Subject: [PATCH 07/19] client: Add some type annotations Also fix a bug in ajax code not spreading the rest field. --- client/js/errors.ts | 1 + client/js/helpers/ajax.ts | 57 ++++++----- client/js/helpers/i18n.ts | 5 +- client/js/templates/App.tsx | 140 +++++++++++++++------------- client/js/templates/EntriesPage.tsx | 109 +++++++++++----------- 5 files changed, 168 insertions(+), 144 deletions(-) diff --git a/client/js/errors.ts b/client/js/errors.ts index 823c83e82..5d78a47c3 100644 --- a/client/js/errors.ts +++ b/client/js/errors.ts @@ -18,6 +18,7 @@ export class TimeoutError extends Error { export class HttpError extends Error { public name: any; + public response: Response; constructor(message) { super(message); diff --git a/client/js/helpers/ajax.ts b/client/js/helpers/ajax.ts index 14d6717b4..77fde62c2 100644 --- a/client/js/helpers/ajax.ts +++ b/client/js/helpers/ajax.ts @@ -3,50 +3,65 @@ import mergeDeepLeft from 'ramda/src/mergeDeepLeft.js'; import pipe from 'ramda/src/pipe.js'; import { HttpError, TimeoutError } from '../errors'; +type Headers = { + [index: string]: string; +}; + +type FetchOptions = { + body?: string; + method?: 'GET' | 'POST' | 'DELETE'; + headers?: Headers; + abortController?: AbortController; + timeout?: number; + failOnHttpErrors?: boolean; +}; + /** * Passing this function as a Promise handler will make the promise fail when the predicate is not true. */ -export const rejectUnless = (pred) => (response) => { - if (pred(response)) { - return response; - } else { - const err = new HttpError(response.statusText); - err.response = response; - throw err; - } -}; +export const rejectUnless = + (pred: (response: Response) => boolean) => (response: Response) => { + if (pred(response)) { + return response; + } else { + const err = new HttpError(response.statusText); + err.response = response; + throw err; + } + }; /** * fetch API considers a HTTP error a successful state. * Passing this function as a Promise handler will make the promise fail when HTTP error occurs. */ -export const rejectIfNotOkay = (response) => { - return rejectUnless((response) => response.ok)(response); +export const rejectIfNotOkay = (response: Response) => { + return rejectUnless((response: Response) => response.ok)(response); }; /** * Override fetch options. */ export const options = - (newOpts) => + (newOpts: FetchOptions) => (fetch) => - (url, opts = {}) => + (url: string, opts: FetchOptions = {}) => fetch(url, mergeDeepLeft(opts, newOpts)); /** * Override just a single fetch option. */ -export const option = (name, value) => options({ [name]: value }); +export const option = (name: string, value) => options({ [name]: value }); /** * Override just headers in fetch. */ -export const headers = (value) => option('headers', value); +export const headers = (value: Headers) => option('headers', value); /** * Override just a single header in fetch. */ -export const header = (name, value) => headers({ [name]: value }); +export const header = (name: string, value: string) => + headers({ [name]: value }); /** * Lift a wrapper function so that it can wrap a function returning more than just a Promise. @@ -78,7 +93,7 @@ export const liftToPromiseField = */ export const makeAbortableFetch = (fetch) => - (url, opts = {}) => { + (url: string, opts: FetchOptions = {}) => { const controller = opts.abortController || new AbortController(); const promise = fetch(url, { signal: controller.signal, @@ -94,7 +109,7 @@ export const makeAbortableFetch = */ export const makeFetchWithTimeout = (abortableFetch) => - (url, opts = {}) => { + (url: string, opts: FetchOptions = {}) => { // offline db consistency requires ajax calls to fail reliably, // so we enforce a default timeout on ajax calls const { timeout = 60000, ...rest } = opts; @@ -130,7 +145,7 @@ export const makeFetchWithTimeout = */ export const makeFetchFailOnHttpErrors = (fetch) => - (url, opts = {}) => { + (url: string, opts: FetchOptions = {}) => { const { failOnHttpErrors = true, ...rest } = opts; const promise = fetch(url, rest); @@ -146,7 +161,7 @@ export const makeFetchFailOnHttpErrors = */ export const makeFetchSupportGetBody = (fetch) => - (url, opts = {}) => { + (url: string, opts: FetchOptions = {}) => { const { body, method, ...rest } = opts; let newUrl = url; @@ -162,7 +177,7 @@ export const makeFetchSupportGetBody = // append the body to the query string newUrl = `${main}${separator}${body.toString()}#${fragments.join('#')}`; // remove the body since it has been moved to URL - newOpts = { method, rest }; + newOpts = { method, ...rest }; } return fetch(newUrl, newOpts); diff --git a/client/js/helpers/i18n.ts b/client/js/helpers/i18n.ts index 12b7554c6..12b8a9ed0 100644 --- a/client/js/helpers/i18n.ts +++ b/client/js/helpers/i18n.ts @@ -6,7 +6,10 @@ import React from 'react'; * The full spec is at https://fatfreeframework.com/3.6/base#format and is * not fully implemented. */ -export function i18nFormat(translated, params) { +export function i18nFormat( + translated: string, + params?: { [index: string]: string }, +): string { let formatted = ''; let curChar; diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index c622c1ede..cc8f6a81d 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -28,6 +28,7 @@ import { LoadingState } from '../requests/LoadingState'; import * as sourceRequests from '../requests/sources'; import locales from '../locales'; import { useEntriesParams } from '../helpers/uri'; +import { NavSource, NavTag } from '../requests/items'; type MessageAction = { label: string; @@ -443,71 +444,82 @@ type AppProps = { configuration: object; }; -export class App extends React.Component { - public state: any; - public setState: React.Dispatch>; - public props: AppProps; +type AppState = { + /** + * tag repository + */ + tags: Array; + tagsState: LoadingState; + + /** + * source repository + */ + sources: Array; + sourcesState: LoadingState; + + /** + * true when sources in the sidebar are expanded + * and we should fetch info about them in API requests. + */ + navSourcesExpanded: boolean; + + /** + * whether off-line mode is enabled + */ + offlineState: boolean; + + /** + * number of unread items + */ + unreadItemsCount: number; + + /** + * number of unread items available offline + */ + unreadItemsOfflineCount: number; + + /** + * number of starred items + */ + starredItemsCount: number; + /** + * number of starred items available offline + */ + starredItemsOfflineCount: number; + + /** + * number of all items + */ + allItemsCount: number; + + /** + * number of all items available offline + */ + allItemsOfflineCount: number; + + /** + * Global message popup. + */ + globalMessage: GlobalMessage | null; +}; + +export class App extends React.Component { constructor(props: AppProps) { super(props); this.state = { - /** - * tag repository - */ tags: [], tagsState: LoadingState.INITIAL, - - /** - * source repository - */ sources: [], sourcesState: LoadingState.INITIAL, - - /** - * true when sources in the sidebar are expanded - * and we should fetch info about them in API requests. - */ navSourcesExpanded: false, - - /** - * whether off-line mode is enabled - */ offlineState: false, - - /** - * number of unread items - */ unreadItemsCount: 0, - - /** - * number of unread items available offline - */ unreadItemsOfflineCount: 0, - - /** - * number of starred items - */ starredItemsCount: 0, - - /** - * number of starred items available offline - */ starredItemsOfflineCount: 0, - - /** - * number of all items - */ allItemsCount: 0, - - /** - * number of all items available offline - */ allItemsOfflineCount: 0, - - /** - * Global message popup. - * @var ?Object.{message: string, actions: Array.}, isError: bool} - */ globalMessage: null, }; @@ -658,7 +670,9 @@ export class App extends React.Component { } } - setGlobalMessage(globalMessage) { + setGlobalMessage( + globalMessage: React.SetStateAction, + ) { if (typeof globalMessage === 'function') { this.setState((state) => ({ globalMessage: globalMessage(state.globalMessage), @@ -670,9 +684,8 @@ export class App extends React.Component { /** * Triggers fetching news from all sources. - * @return Promise */ - reloadAll() { + reloadAll(): Promise { if (!selfoss.isOnline()) { return Promise.resolve(); } @@ -705,11 +718,8 @@ export class App extends React.Component { /** * Obtain a localized message for given key, substituting placeholders for values, when given. - * @param string key - * @param ?array parameters - * @return string */ - _(identifier, params?) { + _(identifier: string, params?: { [index: string]: string }): string { const fallbackLanguage = 'en'; const langKey = `lang_${identifier}`; @@ -742,27 +752,23 @@ export class App extends React.Component { /** * Show error message in the message bar in the UI. - * - * @param {string} message - * @return void */ - showError(message) { + showError(message: string): void { this.showMessage(message, [], true); } /** * Show message in the message bar in the UI. - * - * @param {string} message - * @param {Array.} actions - * @param {bool} isError - * @return void */ - showMessage(message, actions = [], isError = false) { + showMessage( + message: string, + actions: Array = [], + isError: boolean = false, + ): void { this.setGlobalMessage({ message, actions, isError }); } - notifyNewVersion(cb) { + notifyNewVersion(cb: () => void): void { if (!cb) { cb = () => { window.location.reload(); diff --git a/client/js/templates/EntriesPage.tsx b/client/js/templates/EntriesPage.tsx index 4c11fb42c..6879fcd73 100644 --- a/client/js/templates/EntriesPage.tsx +++ b/client/js/templates/EntriesPage.tsx @@ -537,11 +537,6 @@ export function EntriesPage(props: EntriesPageProps) { const initialState = { entries: [], hasMore: false, - /** - * Currently selected entry. - * The id in the location.hash should imply the selected entry. - * It will also be used for keyboard navigation (for finding previous/next). - */ selectedEntry: null, expandedEntries: {}, loadingState: LoadingState.INITIAL, @@ -558,10 +553,25 @@ type StateHolderProps = { unreadItemsCount: number; }; -export class StateHolder extends React.Component { - public state: any; - public props: StateHolderProps; +type StateHolderState = { + entries: Array; + hasMore: boolean; + /** + * Currently selected entry. + * The id in the location.hash should imply the selected entry. + * It will also be used for keyboard navigation (for finding previous/next). + */ + selectedEntry: number | null; + expandedEntries: { + [index: number]: boolean; + }; + loadingState: LoadingState; +}; +export class StateHolder extends React.Component< + StateHolderProps, + StateHolderState +> { constructor(props: StateHolderProps) { super(props); this.state = initialState; @@ -601,9 +611,8 @@ export class StateHolder extends React.Component { /** * Make the given entry currently selected one. - * @param {number|function(number): number} id of entry to select, or a function that transforms a previous id into a new one */ - setSelectedEntry(selectedEntry) { + setSelectedEntry(selectedEntry: React.SetStateAction): void { if (typeof selectedEntry === 'function') { this.setState((state) => ({ selectedEntry: selectedEntry(state.selectedEntry), @@ -615,13 +624,14 @@ export class StateHolder extends React.Component { /** * Get the currently selected entry. - * @return {number} */ - getSelectedEntry() { + getSelectedEntry(): number { return this.state.selectedEntry; } - setExpandedEntries(expandedEntries) { + setExpandedEntries( + expandedEntries: React.SetStateAction<{ [index: number]: boolean }>, + ): void { if (typeof expandedEntries === 'function') { this.setState((state) => ({ expandedEntries: expandedEntries(state.expandedEntries), @@ -631,7 +641,7 @@ export class StateHolder extends React.Component { } } - setEntryExpanded(id, expand) { + setEntryExpanded(id: number, expand: React.SetStateAction): void { if (typeof expand === 'function') { this.setExpandedEntries((oldEntries) => ({ ...oldEntries, @@ -648,37 +658,33 @@ export class StateHolder extends React.Component { /** * Collapse all expanded entries. */ - collapseAllEntries() { + collapseAllEntries(): void { this.setExpandedEntries({}); } /** - * Is given entry expanded? - * @param {number} id of entry to check - * @return {bool} whether it is expanded + * Is entry with given id expanded? */ - isEntryExpanded(entry) { - return this.state.expandedEntries[entry] ?? false; + isEntryExpanded(id: number): boolean { + return this.state.expandedEntries[id] ?? false; } /** - * Toggle expanded state of given entry. - * @param {number} id of entry to toggle + * Toggle expanded state of entry with given id. */ - toggleEntryExpanded(entry) { - if (!entry) { + toggleEntryExpanded(id: number): void { + if (!id) { return; } - this.setEntryExpanded(entry, (expanded) => !(expanded ?? false)); + this.setEntryExpanded(id, (expanded) => !(expanded ?? false)); } /** * Activate entry as if it were clicked. * This will open it, focus it and based on the settings, mark it as read. - * @param {number} id of entry */ - activateEntry(id) { + activateEntry(id: number): void { if (this.props.configuration.autoCollapse) { this.collapseAllEntries(); } @@ -702,13 +708,12 @@ export class StateHolder extends React.Component { /** * Deactivate entry, as if it were clicked. * This will close it and maybe something more. - * @param {number} id of entry */ - deactivateEntry(id) { + deactivateEntry(id: number): void { this.setEntryExpanded(id, false); } - starEntryInView(id, starred) { + starEntryInView(id: number, starred: boolean): void { this.setEntries((entries) => entries.map((entry) => { if (entry.id === id) { @@ -723,7 +728,7 @@ export class StateHolder extends React.Component { ); } - markEntryInView(id, unread) { + markEntryInView(id: number, unread: boolean): void { this.setEntries((entries) => entries.map((entry) => { if (entry.id === id) { @@ -751,7 +756,7 @@ export class StateHolder extends React.Component { }); } - setHasMore(hasMore) { + setHasMore(hasMore: React.SetStateAction): void { if (typeof hasMore === 'function') { this.setState((state) => ({ hasMore: hasMore(state.hasMore), @@ -761,7 +766,7 @@ export class StateHolder extends React.Component { } } - setLoadingState(loadingState) { + setLoadingState(loadingState: React.SetStateAction): void { if (typeof loadingState === 'function') { this.setState((state) => ({ loadingState: loadingState(state.loadingState), @@ -771,7 +776,7 @@ export class StateHolder extends React.Component { } } - getActiveTag() { + getActiveTag(): string | null { const category = this.props.params?.category; if (!category) { return null; @@ -781,7 +786,7 @@ export class StateHolder extends React.Component { : null; } - getActiveSource() { + getActiveSource(): number | null { const category = this.props.params?.category; if (!category) { return null; @@ -791,14 +796,14 @@ export class StateHolder extends React.Component { : null; } - getActiveFilter() { + getActiveFilter(): string | null { return this.props.params?.filter; } /** * Mark all visible items as read */ - markVisibleRead() { + markVisibleRead(): void { const ids = []; const tagUnreadDiff = {}; const sourceUnreadDiff = {}; @@ -927,10 +932,8 @@ export class StateHolder extends React.Component { /** * Requests for an entry to be marked read/unread in the model. - * @param {number} id of entry to mark - * @param {bool|'toggle'} true to mark read, false to mark unread */ - markEntryRead(id, markRead?) { + markEntryRead(id: number, markRead: boolean | 'toggle'): void { // only loggedin users if (!selfoss.isAllowedToWrite()) { console.log('User not allowed to mark an entry (un)read.'); @@ -1011,10 +1014,8 @@ export class StateHolder extends React.Component { /** * Requests for an entry to be marked (un)starred in the model. - * @param {number} id of entry to mark - * @param {bool|'toggle'} true to mark starred, false to mark unstarred */ - markEntryStarred(id, markStarred?) { + markEntryStarred(id: number, markStarred: boolean | 'toggle'): void { // only loggedin users if (!selfoss.isAllowedToWrite()) { console.log('User not allowed to (un)star an entry.'); @@ -1082,7 +1083,7 @@ export class StateHolder extends React.Component { }); } - reload() { + reload(): void { /** * HACK: A counter that is increased every time reload action (r key) is triggered. */ @@ -1094,9 +1095,8 @@ export class StateHolder extends React.Component { /** * get next/prev item - * @param direction */ - nextPrev(direction, open = true) { + nextPrev(direction: Direction, open: boolean = true): void { if (direction != Direction.NEXT && direction != Direction.PREV) { throw new Error('direction must be one of Direction.{PREV,NEXT}'); } @@ -1166,9 +1166,8 @@ export class StateHolder extends React.Component { /** * entry navigation (next/prev) with keys - * @param direction */ - entryNav(direction) { + entryNav(direction: Direction): void { if (direction != Direction.NEXT && direction != Direction.PREV) { throw new Error('direction must be one of Direction.{PREV,NEXT}'); } @@ -1177,7 +1176,7 @@ export class StateHolder extends React.Component { this.nextPrev(direction, open); } - jumpToNext() { + jumpToNext(): void { const selected = this.getSelectedEntry(); if (selected !== null && !this.isEntryExpanded(selected)) { this.activateEntry(selected); @@ -1186,7 +1185,7 @@ export class StateHolder extends React.Component { } } - toggleSelectedStarred() { + toggleSelectedStarred(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1194,7 +1193,7 @@ export class StateHolder extends React.Component { } } - toggleSelectedRead() { + toggleSelectedRead(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1202,11 +1201,11 @@ export class StateHolder extends React.Component { } } - toggleSelectedExpanded() { + toggleSelectedExpanded(): void { this.toggleEntryExpanded(this.getSelectedEntry()); } - openSelectedTarget() { + openSelectedTarget(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1214,7 +1213,7 @@ export class StateHolder extends React.Component { } } - openSelectedTargetAndMarkRead() { + openSelectedTargetAndMarkRead(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1223,7 +1222,7 @@ export class StateHolder extends React.Component { } } - throw(direction) { + throw(direction): void { const selected = this.getSelectedEntry(); if (selected !== null) { From 6ad2f011aff810184d3170b1450ded1a20194b9b Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 29 Jul 2023 01:55:25 +0200 Subject: [PATCH 08/19] client: Add more type annotations --- client/js/Filter.ts | 11 +- client/js/errors.ts | 20 +-- client/js/helpers/ValueListenable.ts | 15 +- client/js/helpers/authorizations.ts | 8 +- client/js/helpers/color.ts | 16 +-- client/js/helpers/configuration.ts | 3 - client/js/helpers/hooks.ts | 18 +-- client/js/helpers/navigation.ts | 10 +- client/js/helpers/uri.ts | 33 +++-- client/js/model/Configuration.ts | 30 ++++ client/js/requests/common.ts | 36 ++++- client/js/requests/items.ts | 198 +++++++++++++++++++++++---- client/js/requests/sources.ts | 110 +++++++++++++-- client/js/requests/tags.ts | 10 +- client/js/selfoss-base.ts | 87 ++++++------ client/js/selfoss-db-offline.ts | 4 +- client/js/selfoss-db-online.ts | 18 ++- client/js/selfoss-db.ts | 4 +- client/js/sharers.tsx | 22 ++- client/js/shortcuts.ts | 56 ++++---- client/js/templates/App.tsx | 82 +++++++---- client/js/templates/EntriesPage.tsx | 6 +- client/js/templates/Item.tsx | 5 +- client/js/templates/LoginForm.tsx | 26 +++- client/js/templates/NavSources.tsx | 7 +- client/js/templates/NavTags.tsx | 5 +- client/js/templates/NavToolBar.tsx | 2 +- client/js/templates/Navigation.tsx | 11 +- client/js/templates/OpmlImport.tsx | 117 ++++++++-------- client/js/templates/SourceParam.tsx | 2 +- client/js/templates/SourcesPage.tsx | 2 +- client/js/templates/Spinner.tsx | 4 +- client/package-lock.json | 8 ++ client/package.json | 1 + client/selfoss-sw-offline.ts | 34 +++-- 35 files changed, 704 insertions(+), 317 deletions(-) delete mode 100644 client/js/helpers/configuration.ts create mode 100644 client/js/model/Configuration.ts diff --git a/client/js/Filter.ts b/client/js/Filter.ts index af55ffd6f..138d6f0ce 100644 --- a/client/js/Filter.ts +++ b/client/js/Filter.ts @@ -1,9 +1,8 @@ /** * Object describing how feed items are filtered in the view. - * @enum {string} */ -export const FilterType = { - NEWEST: 'newest', - UNREAD: 'unread', - STARRED: 'starred', -}; +export enum FilterType { + NEWEST = 'newest', + UNREAD = 'unread', + STARRED = 'starred', +} diff --git a/client/js/errors.ts b/client/js/errors.ts index 5d78a47c3..0ca40d9b4 100644 --- a/client/js/errors.ts +++ b/client/js/errors.ts @@ -1,44 +1,44 @@ export class OfflineStorageNotAvailableError extends Error { - public name: any; + public name: string; - constructor(message = 'Offline storage is not available') { + constructor(message: string = 'Offline storage is not available') { super(message); this.name = 'OfflineStorageNotAvailableError'; } } export class TimeoutError extends Error { - public name: any; + public name: string; - constructor(message) { + constructor(message: string) { super(message); this.name = 'TimeoutError'; } } export class HttpError extends Error { - public name: any; + public name: string; public response: Response; - constructor(message) { + constructor(message: string) { super(message); this.name = 'HttpError'; } } export class LoginError extends Error { - public name: any; + public name: string; - constructor(message) { + constructor(message: string) { super(message); this.name = 'LoginError'; } } export class UnexpectedStateError extends Error { - public name: any; + public name: string; - constructor(message) { + constructor(message: string) { super(message); this.name = 'UnexpectedStateError'; } diff --git a/client/js/helpers/ValueListenable.ts b/client/js/helpers/ValueListenable.ts index 3f174b368..be120f2cd 100644 --- a/client/js/helpers/ValueListenable.ts +++ b/client/js/helpers/ValueListenable.ts @@ -1,7 +1,7 @@ -export class ValueChangeEvent extends Event { - public value: any; +export class ValueChangeEvent extends Event { + public value: T; - constructor(value) { + constructor(value: T) { super('change'); this.value = value; } @@ -10,17 +10,16 @@ export class ValueChangeEvent extends Event { /** * Object storing a value and allowing subscribing to its changes. */ -export class ValueListenable extends EventTarget { - public value: any; - public dispatchEvent: any; +export class ValueListenable extends EventTarget { + public value: T; - constructor(value) { + constructor(value: T) { super(); this.value = value; } - update(value) { + update(value: T) { if (this.value !== value) { this.value = value; diff --git a/client/js/helpers/authorizations.ts b/client/js/helpers/authorizations.ts index d34555a3b..25bf753e3 100644 --- a/client/js/helpers/authorizations.ts +++ b/client/js/helpers/authorizations.ts @@ -1,23 +1,23 @@ import { useListenableValue } from './hooks'; import { useMemo } from 'react'; -export function useLoggedIn() { +export function useLoggedIn(): boolean { return useListenableValue(selfoss.loggedin); } -export function useAllowedToRead() { +export function useAllowedToRead(): boolean { const loggedIn = useLoggedIn(); return useMemo(() => selfoss.isAllowedToRead(), [loggedIn]); } -export function useAllowedToUpdate() { +export function useAllowedToUpdate(): boolean { const loggedIn = useLoggedIn(); return useMemo(() => selfoss.isAllowedToUpdate(), [loggedIn]); } -export function useAllowedToWrite() { +export function useAllowedToWrite(): boolean { const loggedIn = useLoggedIn(); return useMemo(() => selfoss.isAllowedToWrite(), [loggedIn]); diff --git a/client/js/helpers/color.ts b/client/js/helpers/color.ts index bcf85d85d..97180ed75 100644 --- a/client/js/helpers/color.ts +++ b/client/js/helpers/color.ts @@ -1,19 +1,19 @@ /** * Get dark OR bright color depending the color contrast. * - * @param string hexColor color (hex) value - * @param string darkColor dark color value - * @param string brightColor bright color value + * @param hexColor color (hex) value + * @param darkColor dark color value + * @param brightColor bright color value * - * @return string dark OR bright color value + * @return dark OR bright color value * * @see https://24ways.org/2010/calculating-color-contrast/ */ export function colorByBrightness( - hexColor, - darkColor = '#555', - brightColor = '#EEE', -) { + hexColor: string, + darkColor: string = '#555', + brightColor: string = '#EEE', +): string { // Strip hash sign. const color = hexColor.substr(1); const r = parseInt(color.substr(0, 2), 16); diff --git a/client/js/helpers/configuration.ts b/client/js/helpers/configuration.ts deleted file mode 100644 index 230a59652..000000000 --- a/client/js/helpers/configuration.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const ConfigurationContext = createContext(); diff --git a/client/js/helpers/hooks.ts b/client/js/helpers/hooks.ts index 58ddd961b..d9a557942 100644 --- a/client/js/helpers/hooks.ts +++ b/client/js/helpers/hooks.ts @@ -1,15 +1,18 @@ import { useEffect, useState } from 'react'; import { useLocation } from 'react-router'; import { useMediaMatch } from 'rooks'; +import { ValueListenable } from './ValueListenable'; /** * Changes its return value whenever the value of forceReload field * in the location state increases. */ -export function useShouldReload() { +export function useShouldReload(): number { const location = useLocation(); const forceReload = location?.state?.forceReload; - const [oldForceReload, setOldForceReload] = useState(forceReload); + const [oldForceReload, setOldForceReload] = useState( + forceReload, + ); if (oldForceReload !== forceReload) { setOldForceReload(forceReload); @@ -30,18 +33,15 @@ export function useShouldReload() { return reloadCounter; } -export function useIsSmartphone() { +export function useIsSmartphone(): boolean { return useMediaMatch('(max-width: 641px)'); } -/** - * @param {ValueListenable} - */ -export function useListenableValue(valueListenable) { - const [value, setValue] = useState(valueListenable.value); +export function useListenableValue(valueListenable: ValueListenable): T { + const [value, setValue] = useState(valueListenable.value); useEffect(() => { - const listener = (event) => { + const listener = (event: { value: T }) => { setValue(event.value); }; diff --git a/client/js/helpers/navigation.ts b/client/js/helpers/navigation.ts index 93aadfc48..753a32787 100644 --- a/client/js/helpers/navigation.ts +++ b/client/js/helpers/navigation.ts @@ -1,12 +1,12 @@ -export const Direction = { - PREV: 'prev', - NEXT: 'next', -}; +export enum Direction { + PREV = 'prev', + NEXT = 'next', +} /** * autoscroll */ -export function autoScroll(target) { +export function autoScroll(target: HTMLElement): void { const viewportHeight = document.body.clientHeight; const viewportScrollTop = window.scrollY; const targetBb = target.getBoundingClientRect(); diff --git a/client/js/helpers/uri.ts b/client/js/helpers/uri.ts index 0a362dcbe..987fadc7c 100644 --- a/client/js/helpers/uri.ts +++ b/client/js/helpers/uri.ts @@ -1,14 +1,11 @@ import { useLocation, useMatch } from 'react-router'; +import { Location } from 'history'; import { FilterType } from '../Filter'; /** * Converts URL segment to FilterType value. - * - * @param {string} - * - * @returns {FilterType.*} */ -export function filterTypeFromString(type) { +export function filterTypeFromString(type: string): FilterType { if (type == 'newest') { return FilterType.NEWEST; } else if (type == 'unread') { @@ -22,12 +19,8 @@ export function filterTypeFromString(type) { /** * Converts FilterType value to string usable in URL. - * - * @param {FilterType.*} - * - * @returns {string} */ -export function filterTypeToString(type) { +export function filterTypeToString(type: FilterType): string { if (type == FilterType.NEWEST) { return 'newest'; } else if (type == FilterType.UNREAD) { @@ -59,10 +52,17 @@ export function useEntriesParams() { return params; } +type EntriesLinkParams = { + filter?: FilterType; + category?: string; + id?: number; + search?: string; +}; + export function makeEntriesLinkLocation( - location, - { filter, category, id, search }, -) { + location: Location, + { filter, category, id, search }: EntriesLinkParams, +): { pathname: string; search: string } { const queryString = new URLSearchParams(location.search); let path; @@ -97,13 +97,16 @@ export function makeEntriesLinkLocation( }; } -export function makeEntriesLink(location, params) { +export function makeEntriesLink( + location: Location, + params: EntriesLinkParams, +): string { const { pathname, search } = makeEntriesLinkLocation(location, params); return pathname + (search !== '' ? `?${search}` : ''); } -export function forceReload(location) { +export function forceReload(location: Location): void { const state = location.state ?? {}; return { diff --git a/client/js/model/Configuration.ts b/client/js/model/Configuration.ts new file mode 100644 index 000000000..969d11be5 --- /dev/null +++ b/client/js/model/Configuration.ts @@ -0,0 +1,30 @@ +import { createContext } from 'react'; + +export type Configuration = { + homepage: string; + share: string; + wallabag: { url: string; version: number } | null; + wordpress: string | null; + mastodon: string | null; + autoMarkAsRead: boolean; + autoCollapse: boolean; + autoStreamMore: boolean; + openInBackgroundTab: boolean; + loadImagesOnMobile: boolean; + itemsPerPage: number; + unreadOrder: string; + autoHideReadOnMobile: boolean; + scrollToArticleHeader: boolean; + showThumbnails: boolean; + htmlTitle: string; + allowPublicUpdate: boolean; + publicMode: boolean; + authEnabled: boolean; + readingSpeed: number | null; + language: string | null; + userCss: number | null; + userJs: number | null; +}; + +export const ConfigurationContext: React.Context = + createContext(undefined); diff --git a/client/js/requests/common.ts b/client/js/requests/common.ts index 9800633dd..8136c7884 100644 --- a/client/js/requests/common.ts +++ b/client/js/requests/common.ts @@ -1,19 +1,30 @@ import { LoginError } from '../errors'; import * as ajax from '../helpers/ajax'; +import { Configuration } from '../model/Configuration'; export class PasswordHashingError extends Error { - public name: any; + public name: string; - constructor(message) { + constructor(message: string) { super(message); this.name = 'PasswordHashingError'; } } +export type TrivialResponse = { + success: boolean; +}; + +type InstanceInfo = { + version: string; + apiversion: string; + configuration: Configuration; +}; + /** * Gets information about selfoss instance. */ -export function getInstanceInfo() { +export function getInstanceInfo(): Promise { return ajax .get('api/about', { // we want fresh configuration each time @@ -22,10 +33,15 @@ export function getInstanceInfo() { .promise.then((response) => response.json()); } +type Credentials = { + username: string; + password: string; +}; + /** * Signs in user with provided credentials. */ -export function login(credentials) { +export function login(credentials: Credentials): Promise { return ajax .post('login', { body: new URLSearchParams(credentials), @@ -43,7 +59,7 @@ export function login(credentials) { /** * Salt and hash a password. */ -export function hashPassword(password) { +export function hashPassword(password: string): Promise { return ajax .post('api/private/hash-password', { body: new URLSearchParams({ password }), @@ -58,10 +74,16 @@ export function hashPassword(password) { }); } +export type OpmlImportData = { + messages: string[]; +}; + /** * Import OPML file. */ -export function importOpml(file) { +export function importOpml( + file: any, +): Promise<{ response: Response; data: OpmlImportData }> { const data = new FormData(); data.append('opml', file); @@ -86,6 +108,6 @@ export function importOpml(file) { /** * Terminates the active user session. */ -export function logout() { +export function logout(): Promise { return ajax.delete_('api/session/current').promise; } diff --git a/client/js/requests/items.ts b/client/js/requests/items.ts index 193c1ac6a..c2ee6f058 100644 --- a/client/js/requests/items.ts +++ b/client/js/requests/items.ts @@ -1,7 +1,10 @@ +import { TrivialResponse } from './common'; import * as ajax from '../helpers/ajax'; import { unescape } from 'html-escaper'; +import { TagWithUnread } from './tags'; +import { SourceWithUnread } from './sources'; -function safeDate(datetimeString) { +function safeDate(datetimeString: string): Date { const date = new Date(datetimeString); if (isNaN(date.valueOf())) { @@ -14,7 +17,7 @@ function safeDate(datetimeString) { /** * Mark items with given ids as read. */ -export function markAll(ids) { +export function markAll(ids: number[]): Promise { return ajax .post('mark', { headers: { @@ -28,51 +31,125 @@ export function markAll(ids) { /** * Star or unstar item with given id. */ -export function starr(id, starr) { +export function starr(id: number, starr: boolean): Promise { return ajax.post(`${starr ? 'starr' : 'unstarr'}/${id}`).promise; } /** * Mark item with given id as (un)read. */ -export function mark(id, read) { +export function mark(id: number, read: boolean): Promise { return ajax.post(`${read ? 'unmark' : 'mark'}/${id}`).promise; } +export type TagColor = { + foreColor: string; + backColor: string; +}; + +type RawResponseItem = { + id: number; + title: string; + strippedTitle: string; + content: string; + unread: boolean; + starred: boolean; + source: number; + thumbnail: string; + icon: string; + uid: string; + link: string; + wordCount: number; + lengthWithoutTags: number; + datetime: string; + updatetime: string | null; + sourcetitle: string; + author: string; + tags: { + [key: string]: TagColor; + }; +}; + +export type ResponseItem = { + id: number; + title: string; + strippedTitle: string; + content: string; + unread: boolean; + starred: boolean; + source: number; + thumbnail: string; + icon: string; + uid: string; + link: string; + wordCount: number; + lengthWithoutTags: number; + datetime: Date; + updatetime: Date | null; + sourcetitle: string; + author: string; + tags: { + [key: string]: TagColor; + }; +}; + /** * Converts some values like dates in an entry into a objects. */ -function enrichEntry(entry) { +function enrichItem(entry: RawResponseItem): ResponseItem { return { ...entry, link: unescape(entry.link), datetime: safeDate(entry.datetime), - updatetime: entry.updatetime - ? safeDate(entry.updatetime) - : entry.updatetime, + updatetime: + entry.updatetime !== null ? safeDate(entry.updatetime) : null, }; } +type RawItemsResponse = { + lastUpdate: string | null; + entries: Array; + hasMore: boolean; + all: number; + unread: number; + starred: number; + tags: Array; + sources: Array; +}; + +type ItemsResponse = { + lastUpdate: Date | null; + entries: Array; + hasMore: boolean; + all: number; + unread: number; + starred: number; + tags: Array; + sources: Array; +}; + /** * Converts some values like dates in response into a objects. */ -function enrichItemsResponse(data) { +function enrichItemsResponse(data: RawItemsResponse): ItemsResponse { return { ...data, - lastUpdate: data.lastUpdate - ? safeDate(data.lastUpdate) - : data.lastUpdate, - // in getItems - entries: data.entries?.map(enrichEntry), - // in sync - newItems: data.newItems?.map(enrichEntry), + lastUpdate: data.lastUpdate !== null ? safeDate(data.lastUpdate) : null, + entries: data.entries.map(enrichItem), }; } +type QueryFilter = { + fromDatetime?: Date; +}; + /** * Get all items matching given filter. */ -export function getItems(filter, abortController) { +export function getItems( + filter: QueryFilter, + abortController?: AbortController, +): Promise { return ajax .get('', { body: ajax.makeSearchParams({ @@ -87,28 +164,93 @@ export function getItems(filter, abortController) { .then(enrichItemsResponse); } +export type StatusUpdate = { + id: number; + unread?: boolean; + starred?: boolean; + datetime: Date; +}; + +export type SyncParams = { + updatedStatuses?: Array; + tags?: boolean; + sources?: boolean; + itemsStatuses?: boolean; + since?: Date; + itemsHowMany?: number; + itemsSinceId?: any; + itemsNotBefore?: Date; +}; + +export type EntryStatus = { + id: number; + unread: boolean; + starred: boolean; +}; + +export type NavTag = { tag: string; unread: number }; + +export type NavSource = { id: number; title: string; unread: number }; + +export type Stats = { total: number; unread: number; starred: number }; + +export type RawSyncResponse = { + newItems?: RawResponseItem[]; + lastId?: number | null; + lastUpdate: string | null; + stats?: Stats; + tags?: TagWithUnread[]; + sources?: SourceWithUnread[]; + itemUpdates?: EntryStatus[]; +}; + +export type SyncResponse = { + newItems?: ResponseItem[]; + lastId?: number | null; + lastUpdate: Date | null; + stats?: Stats; + tags?: TagWithUnread[]; + sources?: SourceWithUnread[]; + itemUpdates?: EntryStatus[]; +}; + +/** + * Converts some values like dates in response into a objects. + */ +function enrichSyncResponse(data: RawSyncResponse): SyncResponse { + return { + ...data, + lastUpdate: data.lastUpdate !== null ? safeDate(data.lastUpdate) : null, + newItems: data.newItems?.map(enrichItem), + }; +} + /** * Synchronize changes between client and server. */ -export function sync(updatedStatuses, syncParams) { +export function sync( + updatedStatuses: Array, + syncParams: SyncParams, +): { controller: AbortController; promise: Promise } { const params = { ...syncParams, updatedStatuses: syncParams.updatedStatuses - ? syncParams.updatedStatuses.map((status) => { + ? syncParams.updatedStatuses.map((status: StatusUpdate) => { return { ...status, datetime: status.datetime.toISOString(), }; }) : syncParams.updatedStatuses, - }; - if ('since' in params) { - params.since = params.since.toISOString(); - } - if ('itemsNotBefore' in params) { - params.itemsNotBefore = params.itemsNotBefore.toISOString(); - } + since: + 'since' in syncParams ? syncParams.since.toISOString() : undefined, + + itemsNotBefore: + 'itemsNotBefore' in syncParams + ? syncParams.itemsNotBefore.toISOString() + : undefined, + }; const { controller, promise } = ajax.fetch('items/sync', { method: updatedStatuses ? 'POST' : 'GET', @@ -118,7 +260,7 @@ export function sync(updatedStatuses, syncParams) { return { controller, promise: promise - .then((response) => response.json()) - .then(enrichItemsResponse), + .then((response: Response) => response.json()) + .then(enrichSyncResponse), }; } diff --git a/client/js/requests/sources.ts b/client/js/requests/sources.ts index 928c44381..e9b6355e7 100644 --- a/client/js/requests/sources.ts +++ b/client/js/requests/sources.ts @@ -1,9 +1,25 @@ +import { TrivialResponse } from './common'; +import { TagWithUnread } from './tags'; import * as ajax from '../helpers/ajax'; +export type SourceWithUnread = { + id: number; + title: string; + unread: number; +}; + +type UpdateResponse = { + success: true; + id: number; + title: string; + tags: Array; + sources: Array; +}; + /** * Updates source with given ID. */ -export function update(id, values) { +export function update(id: number, values: object): Promise { return ajax .post(`source/${id}`, { headers: { @@ -23,7 +39,7 @@ export function update(id, values) { /** * Triggers an update of the source with given ID. */ -export function refreshSingle(id) { +export function refreshSingle(id: number): Promise { return ajax.post('source/' + id + '/update', { timeout: 0, }).promise; @@ -32,7 +48,7 @@ export function refreshSingle(id) { /** * Triggers an update of all sources. */ -export function refreshAll() { +export function refreshAll(): Promise { return ajax .get('update', { headers: { @@ -46,14 +62,81 @@ export function refreshAll() { /** * Removes source with given ID. */ -export function remove(id) { +export function remove(id: number): Promise { return ajax.delete_(`source/${id}`).promise; } +enum SpoutParameterTypePlain { + Text = 'text', + Url = 'url', + Password = 'password', + Checkbox = 'checkbox', +} + +enum SpoutParameterTypeSelect { + Select = 'select', +} + +enum SpoutParameterValidation { + Alpha = 'alpha', + Email = 'email', + Numeric = 'numeric', + Int = 'int', + Alphanumeric = 'alnum', + NonEmpty = 'notempty', +} + +interface SpoutParameterInfoBase { + title: string; + default: string; + required: boolean; + validation: Array; +} + +interface SpoutParameterInfoPlain extends SpoutParameterInfoBase { + type: SpoutParameterTypePlain; +} + +interface SpoutParameterInfoSelect extends SpoutParameterInfoBase { + type: SpoutParameterTypeSelect; + values: { + [index: string]: string; + }; +} + +type SpoutParameterInfo = SpoutParameterInfoPlain | SpoutParameterInfoSelect; + +type Spout = { + name: string; + description: string; + params: { + [index: string]: SpoutParameterInfo; + }; +}; + +type SourceWithIcon = { + id: number; + title: string; + tags: Array; + spout: string; + params: object; + filter: string | null; + error: string | null; + lastentry: number | null; + icon: string | null; +}; + +type AllSourcesResponse = { + spouts: Array; + sources: Array; +}; + /** * Gets all sources. */ -export function getAllSources(abortController) { +export function getAllSources( + abortController: AbortController, +): Promise { return ajax .get('sources', { abortController, @@ -61,17 +144,28 @@ export function getAllSources(abortController) { .promise.then((response) => response.json()); } +type SpoutsResponse = { + spouts: Array; +}; + /** * Gets list of supported spouts and their paramaters. */ -export function getSpouts() { +export function getSpouts(): Promise { return ajax.get('source').promise.then((response) => response.json()); } +type SpoutParamsResponse = { + id: string; + spout: Spout; +}; + /** * Gets parameters for given spout. */ -export function getSpoutParams(spoutClass) { +export function getSpoutParams( + spoutClass: string, +): Promise { return ajax .get('source/params', { body: ajax.makeSearchParams({ spout: spoutClass }), @@ -82,7 +176,7 @@ export function getSpoutParams(spoutClass) { /** * Gets source unread stats. */ -export function getStats() { +export function getStats(): Promise> { return ajax .get('sources/stats') .promise.then((response) => response.json()); diff --git a/client/js/requests/tags.ts b/client/js/requests/tags.ts index 7d38d139e..6a51e4813 100644 --- a/client/js/requests/tags.ts +++ b/client/js/requests/tags.ts @@ -1,16 +1,22 @@ import * as ajax from '../helpers/ajax'; +export type TagWithUnread = { + tag: string; + color: string; + unread: number; +}; + /** * Get tags for all items. */ -export function getAllTags() { +export function getAllTags(): Promise> { return ajax.get('tags').promise.then((response) => response.json()); } /** * Update tag colour. */ -export function updateTag(tag, color) { +export function updateTag(tag: string, color: string): Promise { return ajax.post('tags/color', { body: ajax.makeSearchParams({ tag, diff --git a/client/js/selfoss-base.ts b/client/js/selfoss-base.ts index 9bfcea8e6..85b691ce3 100644 --- a/client/js/selfoss-base.ts +++ b/client/js/selfoss-base.ts @@ -4,8 +4,9 @@ import { getAllTags } from './requests/tags'; import * as ajax from './helpers/ajax'; import { ValueListenable } from './helpers/ValueListenable'; import { HttpError, TimeoutError } from './errors'; +import { Configuration } from './model/Configuration'; import { LoadingState } from './requests/LoadingState'; -import { createApp } from './templates/App'; +import { App, createApp } from './templates/App'; /** * base javascript application @@ -36,7 +37,7 @@ const selfoss = { /** * initialize application */ - async init() { + async init(): Promise { // Load off-line mode enabledness. selfoss.db.enableOffline.update( window.localStorage.getItem('enableOffline') === 'true', @@ -85,7 +86,7 @@ const selfoss = { } }, - async initMain(configuration) { + async initMain(configuration: Configuration): Promise { selfoss.config = configuration; if (selfoss.db.enableOffline.value) { @@ -136,7 +137,7 @@ const selfoss = { /** * Create basic DOM structure of the page. */ - attachApp(configuration) { + attachApp(configuration: Configuration): void { document.getElementById('js-loading-message')?.remove(); const mainUi = document.createElement('div'); @@ -150,7 +151,7 @@ const selfoss = { root.render( createApp({ basePath, - appRef: (app) => { + appRef: (app: App) => { selfoss.app = app; }, configuration, @@ -160,25 +161,31 @@ const selfoss = { loggedin: new ValueListenable(false), - setSession() { - window.localStorage.setItem('onlineSession', true); + setSession(): void { + window.localStorage.setItem('onlineSession', 'true'); selfoss.loggedin.update(true); }, - clearSession() { + clearSession(): void { window.localStorage.removeItem('onlineSession'); selfoss.loggedin.update(false); }, - hasSession() { + hasSession(): boolean { return selfoss.loggedin.value; }, /** * Try to log in using given credentials - * @return Promise */ - login({ configuration, username, password, enableOffline }) { + login(props: { + configuration: Configuration; + username: string; + password: string; + enableOffline: boolean; + }): Promise { + const { configuration, username, password, enableOffline } = props; + selfoss.db.enableOffline.update(enableOffline); window.localStorage.setItem( 'enableOffline', @@ -228,7 +235,7 @@ const selfoss = { }); }, - setupServiceWorker() { + setupServiceWorker(): void { if ( !('serviceWorker' in navigator) || selfoss.serviceWorkerInitialized @@ -257,7 +264,7 @@ const selfoss = { }); }, - async logout() { + async logout(): Promise { selfoss.clearSession(); selfoss.db.clear(); // will not work after a failure, since storage is nulled @@ -292,10 +299,8 @@ const selfoss = { /** * Checks whether the current user is allowed to perform read operations. - * - * @returns {boolean} */ - isAllowedToRead() { + isAllowedToRead(): boolean { return ( selfoss.hasSession() || !selfoss.config.authEnabled || @@ -305,10 +310,8 @@ const selfoss = { /** * Checks whether the current user is allowed to perform update-tier operations. - * - * @returns {boolean} */ - isAllowedToUpdate() { + isAllowedToUpdate(): boolean { return ( selfoss.hasSession() || !selfoss.config.authEnabled || @@ -318,19 +321,15 @@ const selfoss = { /** * Checks whether the current user is allowed to perform write operations. - * - * @returns {boolean} */ - isAllowedToWrite() { + isAllowedToWrite(): boolean { return selfoss.hasSession() || !selfoss.config.authEnabled; }, /** * Checks whether the current user is allowed to perform write operations. - * - * @returns {boolean} */ - isOnline() { + isOnline(): boolean { return selfoss.db.online; }, @@ -339,7 +338,7 @@ const selfoss = { * * @return true if device resolution smaller equals 1024 */ - isMobile() { + isMobile(): boolean { // first check useragent if (/iPhone|iPod|iPad|Android|BlackBerry/.test(navigator.userAgent)) { return true; @@ -354,7 +353,7 @@ const selfoss = { * * @return true if device resolution smaller equals 1024 */ - isTablet() { + isTablet(): boolean { if (document.body.clientWidth <= 1024) { return true; } @@ -366,7 +365,7 @@ const selfoss = { * * @return true if device resolution smaller equals 1024 */ - isSmartphone() { + isSmartphone(): boolean { if (document.body.clientWidth <= 640) { return true; } @@ -379,20 +378,20 @@ const selfoss = { extensionPoints: { /** * Called when an article is first expanded. - * @param {HTMLElement} HTML element containing the article contents + * @param _contents HTML element containing the article contents */ - processItemContents() {}, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + processItemContents(_contents: HTMLElement) {}, }, /** * refresh stats. * - * @return void - * @param {Number} new all stats - * @param {Number} new unread stats - * @param {Number} new starred stats + * @param all new all stats + * @param unread new unread stats + * @param starred new starred stats */ - refreshStats(all, unread, starred) { + refreshStats(all: number, unread: number, starred: number): void { selfoss.app.setAllItemsCount(all); selfoss.app.setStarredItemsCount(starred); @@ -402,19 +401,16 @@ const selfoss = { /** * refresh unread stats. * - * @return void - * @param {Number} new unread stats + * @param unread new unread stats */ - refreshUnread(unread) { + refreshUnread(unread: number): void { selfoss.app.setUnreadItemsCount(unread); }, /** * refresh current tags. - * - * @return void */ - reloadTags() { + reloadTags(): void { selfoss.app.setTagsState(LoadingState.LOADING); getAllTags() @@ -430,7 +426,7 @@ const selfoss = { }); }, - handleAjaxError(error, tryOffline = true) { + handleAjaxError(error: Error, tryOffline: boolean = true): Promise { if (!(error instanceof HttpError || error instanceof TimeoutError)) { return Promise.reject(error); } @@ -444,8 +440,11 @@ const selfoss = { } }, - listenWaitingSW(reg, callback) { - const awaitStateChange = () => { + listenWaitingSW( + reg: ServiceWorkerRegistration, + callback: (reg: ServiceWorkerRegistration) => void, + ): void { + const awaitStateChange = (): void => { reg.installing.addEventListener('statechange', (event) => { if (event.target.state === 'installed') { callback(reg); diff --git a/client/js/selfoss-db-offline.ts b/client/js/selfoss-db-offline.ts index d55e15018..c47555d9e 100644 --- a/client/js/selfoss-db-offline.ts +++ b/client/js/selfoss-db-offline.ts @@ -257,7 +257,7 @@ selfoss.dbOffline = { }); }, - storeLastUpdate(lastUpdate) { + storeLastUpdate(lastUpdate: Date): Promise { return selfoss.dbOffline._tr('rw', [selfoss.db.storage.stamps], () => { if (lastUpdate) { selfoss.db.storage.stamps.put({ @@ -268,7 +268,7 @@ selfoss.dbOffline = { }); }, - getEntries(fetchParams) { + getEntries(fetchParams: FetchParams) { let hasMore = false; return selfoss.dbOffline ._tr('r', [selfoss.db.storage.entries], () => { diff --git a/client/js/selfoss-db-online.ts b/client/js/selfoss-db-online.ts index 0b9370358..149c801c3 100644 --- a/client/js/selfoss-db-online.ts +++ b/client/js/selfoss-db-online.ts @@ -2,6 +2,11 @@ import selfoss from './selfoss-base'; import * as itemsRequests from './requests/items'; import { LoadingState } from './requests/LoadingState'; import { FilterType } from './Filter'; +import { ResponseItem } from './requests/items'; + +export type FetchParams = { + type: FilterType; +}; selfoss.dbOnline = { syncing: { @@ -50,7 +55,7 @@ selfoss.dbOnline = { return selfoss.dbOnline.syncing.promise; }, - _syncDone(success = true) { + _syncDone(success: boolean = true): void { if (selfoss.dbOnline.syncing.promise) { if (success) { selfoss.dbOnline.syncing.resolve(); @@ -66,10 +71,8 @@ selfoss.dbOnline = { /** * sync server status. - * - * @return Promise */ - sync(updatedStatuses, chained) { + sync(updatedStatuses, chained: boolean): Promise { if (selfoss.dbOnline.syncing.promise && !chained) { if (updatedStatuses) { // Ensure the status queue is not cleared and gets sync'ed at @@ -256,10 +259,11 @@ selfoss.dbOnline = { /** * refresh current items. - * - * @return void */ - getEntries(fetchParams, abortController) { + getEntries( + fetchParams: FetchParams, + abortController: AbortController, + ): Promise<{ entries: ResponseItem[]; hasMore: boolean }> { return itemsRequests .getItems( { diff --git a/client/js/selfoss-db.ts b/client/js/selfoss-db.ts index e3bbc7b6d..c9abf5de4 100644 --- a/client/js/selfoss-db.ts +++ b/client/js/selfoss-db.ts @@ -37,11 +37,11 @@ selfoss.db = { } }, - tryOnline() { + tryOnline(): Promise { return selfoss.db.sync(true); }, - setOffline() { + setOffline(): Promise { if (selfoss.db.storage && !selfoss.db.broken) { selfoss.dbOnline._syncDone(false); selfoss.db.online = false; diff --git a/client/js/sharers.tsx b/client/js/sharers.tsx index 8206408c0..b517145cc 100644 --- a/client/js/sharers.tsx +++ b/client/js/sharers.tsx @@ -3,11 +3,20 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import map from 'ramda/src/map.js'; import selfoss from './selfoss-base'; import * as icons from './icons'; +import { Configuration } from './model/Configuration'; -function materializeSharerIcon(sharer) { +type Sharer = { + label: string; + icon: string | JSX.Element; + action: (params: { url: string; title: string }) => void; + available?: boolean; +}; + +function materializeSharerIcon(sharer: Sharer): Sharer { const { icon } = sharer; return { ...sharer, + // We want to allow people to use or in user.js icon: typeof icon === 'string' && icon.includes('<') ? ( @@ -17,9 +26,14 @@ function materializeSharerIcon(sharer) { }; } -export function useSharers({ configuration, _ }) { - return useMemo(() => { - const availableSharers = { +export function useSharers(args: { + configuration: Configuration; + _: (identifier: string, params?: { [index: string]: string }) => string; +}): Array { + const { configuration, _ } = args; + + return useMemo((): Array => { + const availableSharers: { [key: string]: Sharer } = { a: { label: _('share_native_label'), icon: , diff --git a/client/js/shortcuts.ts b/client/js/shortcuts.ts index 2bb1da448..4ed7a6bc2 100644 --- a/client/js/shortcuts.ts +++ b/client/js/shortcuts.ts @@ -1,12 +1,16 @@ import { tinykeys } from 'tinykeys'; import { Direction } from './helpers/navigation'; +type KeyboardEventHandler = (event: KeyboardEvent) => void; + /** * Decorates an event handler so that it only runs * when not interacting with an input field or lightbox. */ -function ignoreWhenInteracting(handler) { - return (event) => { +function ignoreWhenInteracting( + handler: KeyboardEventHandler, +): KeyboardEventHandler { + return (event: KeyboardEvent): void => { if (selfoss.lightboxActive.value) { return; } @@ -20,7 +24,7 @@ function ignoreWhenInteracting(handler) { active.tagName === 'INPUT' || active.tagName === 'TEXTAREA'); if (!enteringText) { - return handler(event); + handler(event); } }; } @@ -28,136 +32,136 @@ function ignoreWhenInteracting(handler) { /** * Set up shortcuts on document. */ -export default function makeShortcuts() { +export default function makeShortcuts(): () => void { return tinykeys(document, { // 'space': next article - Space: ignoreWhenInteracting((event) => { + Space: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.jumpToNext(); }), // 'n': next article - n: ignoreWhenInteracting((event) => { + n: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.NEXT, false); }), // 'right cursor': next article - ArrowRight: ignoreWhenInteracting((event) => { + ArrowRight: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.entryNav(Direction.NEXT); }), // 'j': next article - j: ignoreWhenInteracting((event) => { + j: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.NEXT, true); }), // 'shift+space': previous article - 'Shift+Space': ignoreWhenInteracting((event) => { + 'Shift+Space': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.PREV, true); }), // 'p': previous article - p: ignoreWhenInteracting((event) => { + p: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.PREV, false); }), // 'left': previous article - ArrowLeft: ignoreWhenInteracting((event) => { + ArrowLeft: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.entryNav(Direction.PREV); }), // 'k': previous article - k: ignoreWhenInteracting((event) => { + k: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.PREV, true); }), // 's': star/unstar - s: ignoreWhenInteracting((event) => { + s: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedStarred(); }), // 'm': mark/unmark - m: ignoreWhenInteracting((event) => { + m: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedRead(); }), // 'o': open/close entry - o: ignoreWhenInteracting((event) => { + o: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedExpanded(); }), // 'Shift + o': close open entries - 'Shift+o': ignoreWhenInteracting((event) => { + 'Shift+o': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.collapseAllEntries(); }), // 'v': open target - v: ignoreWhenInteracting((event) => { + v: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.openSelectedTarget(); }), // 'Shift + v': open target and mark read - 'Shift+v': ignoreWhenInteracting((event) => { + 'Shift+v': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.openSelectedTargetAndMarkRead(); }), // 'r': Reload the current view - r: ignoreWhenInteracting((event) => { + r: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.reload(); }), // 'Shift + r': Refresh sources - 'Shift+r': ignoreWhenInteracting((event) => { + 'Shift+r': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); document.querySelector('#nav-refresh').click(); }), // 'Control+m': mark all as read - 'Control+m': ignoreWhenInteracting((event) => { + 'Control+m': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); document.querySelector('#nav-mark').click(); }), // 't': throw (mark as read & open next) - t: ignoreWhenInteracting((event) => { + t: ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.throw(Direction.NEXT); }), // throw (mark as read & open previous) - 'Shift+t': ignoreWhenInteracting((event) => { + 'Shift+t': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); selfoss.entriesPage?.throw(Direction.PREV); }), // 'Shift+n': switch to newest items overview / menu item - 'Shift+n': ignoreWhenInteracting((event) => { + 'Shift+n': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); document.querySelector('#nav-filter-newest').click(); }), // 'Shift+u': switch to unread items overview / menu item - 'Shift+u': ignoreWhenInteracting((event) => { + 'Shift+u': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); document.querySelector('#nav-filter-unread').click(); }), // 'Shift+s': switch to starred items overview / menu item - 'Shift+s': ignoreWhenInteracting((event) => { + 'Shift+s': ignoreWhenInteracting((event: KeyboardEvent): void => { event.preventDefault(); document.querySelector('#nav-filter-starred').click(); }), diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index cc8f6a81d..311f1a14e 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -21,9 +21,9 @@ import SearchList from './SearchList'; import makeShortcuts from '../shortcuts'; import * as icons from '../icons'; import { useAllowedToRead, useAllowedToWrite } from '../helpers/authorizations'; -import { ConfigurationContext } from '../helpers/configuration'; import { useIsSmartphone, useListenableValue } from '../helpers/hooks'; import { i18nFormat, LocalizationContext } from '../helpers/i18n'; +import { Configuration, ConfigurationContext } from '../model/Configuration'; import { LoadingState } from '../requests/LoadingState'; import * as sourceRequests from '../requests/sources'; import locales from '../locales'; @@ -41,7 +41,11 @@ type GlobalMessage = { isError?: boolean; }; -function handleNavToggle({ event, setNavExpanded }) { +function handleNavToggle(args: { + event: Event; + setNavExpanded: React.Dispatch>; +}): void { + const { event, setNavExpanded } = args; event.preventDefault(); // show hide navigation for mobile version @@ -49,7 +53,7 @@ function handleNavToggle({ event, setNavExpanded }) { window.scrollTo({ top: 0 }); } -function dismissMessage(event) { +function dismissMessage(event: Event): void { selfoss.app.setGlobalMessage(null); event.stopPropagation(); } @@ -63,7 +67,7 @@ type MessageProps = { * It watches globalMessage and updates/shows/hides itself as necessary * when the value changes. */ -function Message(props: MessageProps) { +function Message(props: MessageProps): JSX.Element | null { const { message } = props; // Whenever message changes, dismiss it after 15 seconds. @@ -96,7 +100,7 @@ function Message(props: MessageProps) { ) : null; } -function NotFound() { +function NotFound(): JSX.Element { const location = useLocation(); const _ = useContext(LocalizationContext); return

{_('error_invalid_subsection') + location.pathname}

; @@ -106,10 +110,10 @@ type CheckAuthorizationProps = { isAllowed: boolean; returnLocation?: string; _: (translated: string, params?: { [index: string]: string }) => string; - children?: any; + children: React.ReactNode; }; -function CheckAuthorization(props: CheckAuthorizationProps) { +function CheckAuthorization(props: CheckAuthorizationProps): React.ReactNode { const { isAllowed, returnLocation, _, children } = props; const navigate = useNavigate(); @@ -136,7 +140,7 @@ function CheckAuthorization(props: CheckAuthorizationProps) { type EntriesFilterProps = { entriesRef: React.RefCallback; setNavExpanded: React.Dispatch>; - configuration: object; + configuration: Configuration; navSourcesExpanded: boolean; unreadItemsCount: number; setGlobalUnreadCount: React.Dispatch>; @@ -191,7 +195,7 @@ type PureAppProps = { reloadAll: () => void; }; -function PureApp(props: PureAppProps) { +function PureApp(props: PureAppProps): JSX.Element { const { navSourcesExpanded, setNavSourcesExpanded, @@ -441,7 +445,7 @@ function PureApp(props: PureAppProps) { } type AppProps = { - configuration: object; + configuration: Configuration; }; type AppState = { @@ -542,7 +546,7 @@ export class App extends React.Component { this.reloadAll = this.reloadAll.bind(this); } - setTags(tags) { + setTags(tags: React.SetStateAction>): void { if (typeof tags === 'function') { this.setState((state) => ({ tags: tags(state.tags), @@ -552,7 +556,7 @@ export class App extends React.Component { } } - setTagsState(tagsState) { + setTagsState(tagsState: React.SetStateAction): void { if (typeof tagsState === 'function') { this.setState((state) => ({ tagsState: tagsState(state.tagsState), @@ -562,7 +566,7 @@ export class App extends React.Component { } } - setSources(sources) { + setSources(sources: React.SetStateAction>): void { if (typeof sources === 'function') { this.setState((state) => ({ sources: sources(state.sources), @@ -572,7 +576,7 @@ export class App extends React.Component { } } - setSourcesState(sourcesState) { + setSourcesState(sourcesState: React.SetStateAction): void { if (typeof sourcesState === 'function') { this.setState((state) => ({ sourcesState: sourcesState(state.sourcesState), @@ -582,7 +586,7 @@ export class App extends React.Component { } } - setOfflineState(offlineState) { + setOfflineState(offlineState: React.SetStateAction): void { if (typeof offlineState === 'function') { this.setState((state) => ({ offlineState: offlineState(state.offlineState), @@ -592,7 +596,9 @@ export class App extends React.Component { } } - setNavSourcesExpanded(navSourcesExpanded) { + setNavSourcesExpanded( + navSourcesExpanded: React.SetStateAction, + ): void { if (typeof navSourcesExpanded === 'function') { this.setState((state) => ({ navSourcesExpanded: navSourcesExpanded( @@ -604,7 +610,7 @@ export class App extends React.Component { } } - setUnreadItemsCount(unreadItemsCount) { + setUnreadItemsCount(unreadItemsCount: React.SetStateAction): void { if (typeof unreadItemsCount === 'function') { this.setState((state) => ({ unreadItemsCount: unreadItemsCount(state.unreadItemsCount), @@ -614,7 +620,9 @@ export class App extends React.Component { } } - setUnreadItemsOfflineCount(unreadItemsOfflineCount) { + setUnreadItemsOfflineCount( + unreadItemsOfflineCount: React.SetStateAction, + ): void { if (typeof unreadItemsOfflineCount === 'function') { this.setState((state) => ({ unreadItemsOfflineCount: unreadItemsOfflineCount( @@ -626,7 +634,9 @@ export class App extends React.Component { } } - setStarredItemsCount(starredItemsCount) { + setStarredItemsCount( + starredItemsCount: React.SetStateAction, + ): void { if (typeof starredItemsCount === 'function') { this.setState((state) => ({ starredItemsCount: starredItemsCount(state.starredItemsCount), @@ -636,7 +646,9 @@ export class App extends React.Component { } } - setStarredItemsOfflineCount(starredItemsOfflineCount) { + setStarredItemsOfflineCount( + starredItemsOfflineCount: React.SetStateAction, + ): void { if (typeof starredItemsOfflineCount === 'function') { this.setState((state) => ({ starredItemsOfflineCount: starredItemsOfflineCount( @@ -648,7 +660,7 @@ export class App extends React.Component { } } - setAllItemsCount(allItemsCount) { + setAllItemsCount(allItemsCount: React.SetStateAction): void { if (typeof allItemsCount === 'function') { this.setState((state) => ({ allItemsCount: allItemsCount(state.allItemsCount), @@ -658,7 +670,9 @@ export class App extends React.Component { } } - setAllItemsOfflineCount(allItemsOfflineCount) { + setAllItemsOfflineCount( + allItemsOfflineCount: React.SetStateAction, + ): void { if (typeof allItemsOfflineCount === 'function') { this.setState((state) => ({ allItemsOfflineCount: allItemsOfflineCount( @@ -672,7 +686,7 @@ export class App extends React.Component { setGlobalMessage( globalMessage: React.SetStateAction, - ) { + ): void { if (typeof globalMessage === 'function') { this.setState((state) => ({ globalMessage: globalMessage(state.globalMessage), @@ -685,7 +699,7 @@ export class App extends React.Component { /** * Triggers fetching news from all sources. */ - reloadAll(): Promise { + reloadAll(): Promise { if (!selfoss.isOnline()) { return Promise.resolve(); } @@ -783,7 +797,11 @@ export class App extends React.Component { ]); } - refreshTagSourceUnread(tagCounts, sourceCounts, diff = true) { + refreshTagSourceUnread( + tagCounts: { [index: string]: number }, + sourceCounts: { [index: number]: number }, + diff: boolean = true, + ): void { this.setTags((tags) => tags.map((tag) => { if (!(tag.tag in tagCounts)) { @@ -825,7 +843,9 @@ export class App extends React.Component { ); } - refreshOfflineCounts(offlineCounts) { + refreshOfflineCounts(offlineCounts: { + [index in 'unread' | 'starred' | 'newest']: number | 'keep'; + }): void { for (const [kind, newCount] of Object.entries(offlineCounts)) { if (newCount === 'keep') { continue; @@ -841,7 +861,7 @@ export class App extends React.Component { } } - render() { + render(): JSX.Element { return ( @@ -877,7 +897,13 @@ export class App extends React.Component { * Creates the selfoss single-page application * with the required contexts. */ -export function createApp({ basePath, appRef, configuration }) { +export function createApp(args: { + basePath: string; + appRef: React.Ref; + configuration: Configuration; +}): JSX.Element { + const { basePath, appRef, configuration } = args; + return ( diff --git a/client/js/templates/EntriesPage.tsx b/client/js/templates/EntriesPage.tsx index 6879fcd73..aa596021a 100644 --- a/client/js/templates/EntriesPage.tsx +++ b/client/js/templates/EntriesPage.tsx @@ -20,11 +20,11 @@ import { useAllowedToUpdate, useAllowedToWrite, } from '../helpers/authorizations'; -import { ConfigurationContext } from '../helpers/configuration'; import { autoScroll, Direction } from '../helpers/navigation'; import { LocalizationContext } from '../helpers/i18n'; import { useShouldReload } from '../helpers/hooks'; import { forceReload, makeEntriesLinkLocation } from '../helpers/uri'; +import { ConfigurationContext } from '../model/Configuration'; import { HttpError } from '../errors'; import { useNavigate } from 'react-router'; @@ -1222,7 +1222,7 @@ export class StateHolder extends React.Component< } } - throw(direction): void { + throw(direction: Direction): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1252,7 +1252,7 @@ export class StateHolder extends React.Component< } type StateHolderOuterProps = { - configuration: object; + configuration: Configuration; setNavExpanded: React.Dispatch>; navSourcesExpanded: boolean; setGlobalUnreadCount: React.Dispatch>; diff --git a/client/js/templates/Item.tsx b/client/js/templates/Item.tsx index 84a0629a2..94c0e106f 100644 --- a/client/js/templates/Item.tsx +++ b/client/js/templates/Item.tsx @@ -18,11 +18,12 @@ import { makeEntriesLinkLocation, } from '../helpers/uri'; import * as icons from '../icons'; -import { ConfigurationContext } from '../helpers/configuration'; import { LocalizationContext } from '../helpers/i18n'; import { Direction } from '../helpers/navigation'; +import { ConfigurationContext } from '../model/Configuration'; import { useSharers } from '../sharers'; import Lightbox from 'yet-another-react-lightbox'; +import { TagColor } from '../requests/items'; // TODO: do the search highlights client-side function reHighlight(text) { @@ -205,7 +206,7 @@ function ShareButton(props: ShareButtonProps) { type ItemTagProps = { tag: string; - color: object; + color: TagColor; }; function ItemTag(props: ItemTagProps) { diff --git a/client/js/templates/LoginForm.tsx b/client/js/templates/LoginForm.tsx index 79b120212..d19f68665 100644 --- a/client/js/templates/LoginForm.tsx +++ b/client/js/templates/LoginForm.tsx @@ -1,10 +1,12 @@ import React, { useCallback, useContext, useState } from 'react'; import classNames from 'classnames'; +import { History } from 'history'; import { SpinnerBig } from './Spinner'; import { useLocation, useNavigate } from 'react-router'; +import { Configuration } from '../model/Configuration'; import { HttpError, LoginError } from '../errors'; -import { ConfigurationContext } from '../helpers/configuration'; import { LocalizationContext } from '../helpers/i18n'; +import { ConfigurationContext } from '../model/Configuration'; function handleLogIn({ event, @@ -15,6 +17,15 @@ function handleLogIn({ password, enableOffline, returnLocation, +}: { + event: React.FormEvent; + configuration: Configuration; + history: History; + setLoading: React.Dispatch>; + username: string; + password: string; + enableOffline: boolean; + returnLocation: string; }) { event.preventDefault(); @@ -25,7 +36,7 @@ function handleLogIn({ .then(() => { navigate(returnLocation); }) - .catch((err) => { + .catch((err: Error) => { const message = err instanceof LoginError ? selfoss.app._('login_invalid_credentials') @@ -66,7 +77,7 @@ export default function LoginForm(props: LoginFormProps) { const returnLocation = location?.state?.returnLocation ?? '/'; const formOnSubmit = useCallback( - (event) => + (event: React.FormEvent) => handleLogIn({ event, configuration, @@ -88,17 +99,20 @@ export default function LoginForm(props: LoginFormProps) { ); const usernameOnChange = useCallback( - (event) => setUsername(event.target.value), + (event: React.ChangeEvent) => + setUsername(event.target.value), [], ); const passwordOnChange = useCallback( - (event) => setPassword(event.target.value), + (event: React.ChangeEvent) => + setPassword(event.target.value), [], ); const offlineOnChange = useCallback( - (event) => setEnableOffline(event.target.checked), + (event: React.ChangeEvent) => + setEnableOffline(event.target.checked), [setEnableOffline], ); diff --git a/client/js/templates/NavSources.tsx b/client/js/templates/NavSources.tsx index 6ac29979f..f325df65a 100644 --- a/client/js/templates/NavSources.tsx +++ b/client/js/templates/NavSources.tsx @@ -14,6 +14,7 @@ import { LoadingState } from '../requests/LoadingState'; import * as sourceRequests from '../requests/sources'; import * as icons from '../icons'; import { LocalizationContext } from '../helpers/i18n'; +import { NavSource } from '../requests/items'; function handleTitleClick({ setExpanded, @@ -49,7 +50,7 @@ function handleTitleClick({ } type SourceProps = { - source: object; + source: NavSource; active: boolean; collapseNav: () => void; }; @@ -91,8 +92,8 @@ type NavSourcesProps = { setNavSourcesExpanded: React.Dispatch>; sourcesState: LoadingState; setSourcesState: React.Dispatch>; - sources: Array; - setSources: React.Dispatch>>; + sources: Array; + setSources: React.Dispatch>>; }; export default function NavSources(props: NavSourcesProps) { diff --git a/client/js/templates/NavTags.tsx b/client/js/templates/NavTags.tsx index 7113592a1..d24eb3438 100644 --- a/client/js/templates/NavTags.tsx +++ b/client/js/templates/NavTags.tsx @@ -13,9 +13,10 @@ import { Collapse } from '@kunukn/react-collapse'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as icons from '../icons'; import { LocalizationContext } from '../helpers/i18n'; +import { NavTag } from '../requests/items'; type TagProps = { - tag: object | null; + tag: NavTag | null; active: boolean; collapseNav: () => void; }; @@ -82,7 +83,7 @@ function Tag(props: TagProps) { type NavTagsProps = { setNavExpanded: React.Dispatch>; - tags: Array; + tags: Array; }; export default function NavTags(props: NavTagsProps) { diff --git a/client/js/templates/NavToolBar.tsx b/client/js/templates/NavToolBar.tsx index 76ba3095c..bebd02791 100644 --- a/client/js/templates/NavToolBar.tsx +++ b/client/js/templates/NavToolBar.tsx @@ -7,9 +7,9 @@ import { useAllowedToWrite, useLoggedIn, } from '../helpers/authorizations'; -import { ConfigurationContext } from '../helpers/configuration'; import { LocalizationContext } from '../helpers/i18n'; import { useForceReload } from '../helpers/uri'; +import { ConfigurationContext } from '../model/Configuration'; function handleReloadAll({ reloadAll, setReloading, setNavExpanded }) { setReloading(true); diff --git a/client/js/templates/Navigation.tsx b/client/js/templates/Navigation.tsx index 86696d36d..0c58b0a7a 100644 --- a/client/js/templates/Navigation.tsx +++ b/client/js/templates/Navigation.tsx @@ -11,6 +11,7 @@ import * as icons from '../icons'; import { LoadingState } from '../requests/LoadingState'; import { useAllowedToWrite } from '../helpers/authorizations'; import { LocalizationContext } from '../helpers/i18n'; +import { NavSource, NavTag } from '../requests/items'; type NavigationProps = { entriesPage: EntriesPage | null; @@ -26,13 +27,13 @@ type NavigationProps = { starredItemsOfflineCount: number; sourcesState: LoadingState; setSourcesState: React.Dispatch>; - sources: Array; - setSources: React.Dispatch>>; - tags: Array; - reloadAll: React.Dispatch>>; + sources: Array; + setSources: React.Dispatch>>; + tags: Array; + reloadAll: React.Dispatch>>; }; -export default function Navigation(props: NavigationProps) { +export default function Navigation(props: NavigationProps): JSX.Element { const { entriesPage, setNavExpanded, diff --git a/client/js/templates/OpmlImport.tsx b/client/js/templates/OpmlImport.tsx index 569dd5ba0..b4697f377 100644 --- a/client/js/templates/OpmlImport.tsx +++ b/client/js/templates/OpmlImport.tsx @@ -3,77 +3,86 @@ import { useOnline } from 'rooks'; import { Link, useNavigate } from 'react-router'; import { LoadingState } from '../requests/LoadingState'; import { HttpError, UnexpectedStateError } from '../errors'; -import { importOpml } from '../requests/common'; +import { importOpml, OpmlImportData } from '../requests/common'; type OpmlImportProps = { setTitle: (title: string | null) => void; }; -export default function OpmlImport(props: OpmlImportProps) { +export default function OpmlImport(props: OpmlImportProps): JSX.Element { const { setTitle } = props; - const [state, setState] = useState(LoadingState.INITIAL); - const [message, setMessage] = useState(null); - const fileEntry = useRef(); + const [state, setState] = useState(LoadingState.INITIAL); + const [message, setMessage] = useState(null); + const fileEntry = useRef(null); const navigate = useNavigate(); const submit = useCallback( - (event) => { + (event: React.FormEvent) => { event.preventDefault(); setState(LoadingState.LOADING); const file = fileEntry.current.files[0]; importOpml(file) - .then(({ response, data }) => { - const { messages } = data; + .then( + ({ + response, + data, + }: { + response: Response; + data: OpmlImportData; + }) => { + const { messages } = data; - if (response.status === 200) { - setState(LoadingState.SUCCESS); - setMessage( -

-

    - {messages.map((msg, i) => ( -
  • {msg}
  • - ))} -
- You might want to{' '} - update now or{' '} - view your feeds. -

, - ); - } else if (response.status === 202) { - setState(LoadingState.FAILURE); - setMessage( -

- The following feeds could not be imported: -
-

    - {messages.map((msg, i) => ( -
  • {msg}
  • - ))} -
-

, - ); - } else if (response.status === 400) { - setState(LoadingState.FAILURE); - setMessage( -

- There was a problem importing your OPML file: -
-

    - {messages.map((msg, i) => ( -
  • {msg}
  • - ))} -
-

, - ); - } else { - throw new UnexpectedStateError( - `OPML import handler received status ${response.status}. This should not happen.`, - ); - } - }) + if (response.status === 200) { + setState(LoadingState.SUCCESS); + setMessage( +

+

    + {messages.map((msg, i) => ( +
  • {msg}
  • + ))} +
+ You might want to{' '} + update now or{' '} + view your feeds. +

, + ); + } else if (response.status === 202) { + setState(LoadingState.FAILURE); + setMessage( +

+ The following feeds could not be imported: +
+

    + {messages.map((msg, i) => ( +
  • {msg}
  • + ))} +
+

, + ); + } else if (response.status === 400) { + setState(LoadingState.FAILURE); + setMessage( +

+ There was a problem importing your OPML + file: +
+

    + {messages.map((msg, i) => ( +
  • {msg}
  • + ))} +
+

, + ); + } else { + throw new UnexpectedStateError( + `OPML import handler received status ${response.status}. This should not happen.`, + ); + } + }, + ) .catch((error) => { if ( error instanceof HttpError && diff --git a/client/js/templates/SourceParam.tsx b/client/js/templates/SourceParam.tsx index 4f3f2655c..28b73b021 100644 --- a/client/js/templates/SourceParam.tsx +++ b/client/js/templates/SourceParam.tsx @@ -11,7 +11,7 @@ type SourceParamProps = { setDirty: React.Dispatch>; }; -export default function SourceParam(props: SourceParamProps) { +export default function SourceParam(props: SourceParamProps): JSX.Element { const { spoutParamName, spoutParam, diff --git a/client/js/templates/SourcesPage.tsx b/client/js/templates/SourcesPage.tsx index bde112949..50d53d5c7 100644 --- a/client/js/templates/SourcesPage.tsx +++ b/client/js/templates/SourcesPage.tsx @@ -112,7 +112,7 @@ function loadSources({ }); } -export default function SourcesPage() { +export default function SourcesPage(): JSX.Element { const [spouts, setSpouts] = useState([]); const [sources, setSources] = useState([]); diff --git a/client/js/templates/Spinner.tsx b/client/js/templates/Spinner.tsx index 1d9a168cb..1c46eaba9 100644 --- a/client/js/templates/Spinner.tsx +++ b/client/js/templates/Spinner.tsx @@ -8,7 +8,7 @@ type SpinnerProps = { size?: SizeProp; }; -export function Spinner(props: SpinnerProps) { +export function Spinner(props: SpinnerProps): JSX.Element { const { label, size } = props; return ( @@ -31,7 +31,7 @@ type SpinnerBigProps = { label: string; }; -export function SpinnerBig(props: SpinnerBigProps) { +export function SpinnerBig(props: SpinnerBigProps): JSX.Element { const { label } = props; return ( diff --git a/client/package-lock.json b/client/package-lock.json index 9d3d47884..a0ca58bff 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -38,6 +38,7 @@ "@parcel/transformer-image": "^2.0.1", "@parcel/transformer-sass": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0", + "@types/history": "^4.7.11", "autoprefixer": "^10.4.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", @@ -2551,6 +2552,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/client/package.json b/client/package.json index ca89156f6..c6ec42b81 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@parcel/transformer-image": "^2.0.1", "@parcel/transformer-sass": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0", + "@types/history": "^4.7.11", "autoprefixer": "^10.4.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/client/selfoss-sw-offline.ts b/client/selfoss-sw-offline.ts index 3a372df9d..25156c8ec 100644 --- a/client/selfoss-sw-offline.ts +++ b/client/selfoss-sw-offline.ts @@ -1,32 +1,41 @@ /* eslint-env worker, serviceworker */ +/// import { manifest, version } from '@parcel/service-worker'; -async function install() { +// Default type of `self` is `WorkerGlobalScope & typeof globalThis` +// https://github.com/microsoft/TypeScript/issues/14877 +declare const self: ServiceWorkerGlobalScope; + +async function install(): Promise { const cache = await caches.open(version); - const entriesToCache = manifest + const entriesToCache: string[] = manifest // We need to pass index.html through PHP to perform templating. - .map((entry) => (entry === 'index.html' ? './' : entry)); + .map((entry: string) => (entry === 'index.html' ? './' : entry)); await cache.addAll(entriesToCache); } -self.addEventListener('install', (event) => event.waitUntil(install())); +self.addEventListener('install', (event: ExtendableEvent) => + event.waitUntil(install()), +); -async function activate() { +async function activate(): Promise { const keys = await caches.keys(); await Promise.all( keys .filter( - (key) => + (key: string) => !(key === version || key === 'userCss' || key === 'userJs'), ) - .map((key) => caches.delete(key)), + .map((key: string) => caches.delete(key)), ); } -self.addEventListener('activate', (event) => event.waitUntil(activate())); +self.addEventListener('activate', (event: ExtendableEvent) => + event.waitUntil(activate()), +); -self.addEventListener('fetch', (event) => { +self.addEventListener('fetch', (event: FetchEvent) => { if ( event.request.method !== 'GET' || event.request.headers.get('X-Requested-With') === 'XMLHttpRequest' @@ -37,12 +46,15 @@ self.addEventListener('fetch', (event) => { event.respondWith( caches .match(event.request) - .then((cachedResponse) => cachedResponse || fetch(event.request)) + .then( + (cachedResponse: Response | undefined) => + cachedResponse || fetch(event.request), + ) .catch(() => caches.match('./')), ); }); -self.addEventListener('message', (messageEvent) => { +self.addEventListener('message', (messageEvent: ExtendableMessageEvent) => { if (messageEvent.data === 'skipWaiting') { return self.skipWaiting(); } From 171ad2ab61ae311ca78d12db41f8816bf1fdce76 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 28 Dec 2024 03:08:39 +0100 Subject: [PATCH 09/19] client: Port Db to classes --- client/js/selfoss-base.ts | 7 ++ client/js/selfoss-db-offline.ts | 149 ++++++++++++++------------------ client/js/selfoss-db-online.ts | 122 +++++++++++++------------- client/js/selfoss-db.ts | 67 +++++++------- 4 files changed, 171 insertions(+), 174 deletions(-) diff --git a/client/js/selfoss-base.ts b/client/js/selfoss-base.ts index 85b691ce3..cb063488c 100644 --- a/client/js/selfoss-base.ts +++ b/client/js/selfoss-base.ts @@ -7,6 +7,9 @@ import { HttpError, TimeoutError } from './errors'; import { Configuration } from './model/Configuration'; import { LoadingState } from './requests/LoadingState'; import { App, createApp } from './templates/App'; +import DbOnline from './selfoss-db-online'; +import DbOffline from './selfoss-db-offline'; +import Db from './selfoss-db'; /** * base javascript application @@ -34,6 +37,10 @@ const selfoss = { */ lightboxActive: new ValueListenable(false), + db: new Db(), + dbOnline: new DbOnline(), + dbOffline: new DbOffline(), + /** * initialize application */ diff --git a/client/js/selfoss-db-offline.ts b/client/js/selfoss-db-offline.ts index c47555d9e..15061b65a 100644 --- a/client/js/selfoss-db-offline.ts +++ b/client/js/selfoss-db-offline.ts @@ -6,15 +6,15 @@ import { FilterType } from './Filter'; const ENTRY_STATUS_NAMES = ['unread', 'starred']; -selfoss.dbOffline = { +export default class DbOffline { /** @var Date the datetime of the newest garbage collected entry, i.e. deleted because not of interest. */ - newestGCedEntry: null, - offlineDays: 10, + public newestGCedEntry: Date | null = null; + public offlineDays: number = 10; - lastItemId: null, - newerEntriesMissing: false, - shouldLoadEntriesOnline: false, - olderEntriesOnline: false, + public lastItemId: number | null = null; + public newerEntriesMissing: boolean = false; + public shouldLoadEntriesOnline: boolean = false; + public olderEntriesOnline: boolean = false; _tr(...args) { return selfoss.db.storage.transaction(...args).catch((error) => { @@ -28,12 +28,12 @@ selfoss.dbOffline = { // If this is a QuotaExceededError, garbage collect more // entries and hope it helps. if (error.name === Dexie.errnames.QuotaExceeded) { - selfoss.dbOffline.GCEntries(true); + this.GCEntries(true); } return Promise.reject(error); }); - }, + } init() { if (!selfoss.db.enableOffline.value || selfoss.db.storage) { @@ -55,7 +55,7 @@ selfoss.dbOffline = { 'r', [selfoss.db.storage.entries, selfoss.db.storage.stamps], () => { - selfoss.dbOffline._memLastItemId(); + this._memLastItemId(); selfoss.db.storage.stamps.get( 'lastItemsUpdate', (stamp) => { @@ -63,8 +63,7 @@ selfoss.dbOffline = { selfoss.db.lastUpdate = stamp.datetime; selfoss.dbOnline.firstSync = false; } else { - selfoss.dbOffline.shouldLoadEntriesOnline = - true; + this.shouldLoadEntriesOnline = true; } }, ); @@ -72,18 +71,14 @@ selfoss.dbOffline = { 'newestGCedEntry', (stamp) => { if (stamp) { - selfoss.dbOffline.newestGCedEntry = - stamp.datetime; + this.newestGCedEntry = stamp.datetime; } const limit = new Date( Date.now() - 3 * 24 * 3600 * 1000, ); - if ( - !stamp || - selfoss.dbOffline.newestGCedEntry < limit - ) { - selfoss.dbOffline.newestGCedEntry = new Date( + if (!stamp || this.newestGCedEntry < limit) { + this.newestGCedEntry = new Date( Date.now() - 24 * 3600 * 1000, ); } @@ -94,15 +89,15 @@ selfoss.dbOffline = { .then(() => { const offlineDays = window.localStorage.getItem('offlineDays'); if (offlineDays !== null) { - selfoss.dbOffline.offlineDays = parseInt(offlineDays); + this.offlineDays = parseInt(offlineDays); } // The newest garbage collected entry is either what's already // in the offline db or if more recent the entry older than // offlineDays ago. - selfoss.dbOffline.newestGCedEntry = new Date( + this.newestGCedEntry = new Date( Math.max( - selfoss.dbOffline.newestGCedEntry, - Date.now() - selfoss.dbOffline.offlineDays * 86400000, + this.newestGCedEntry, + Date.now() - this.offlineDays * 86400000, ), ); @@ -131,14 +126,14 @@ selfoss.dbOffline = { selfoss.db.tryOnline().then(() => { selfoss.reloadTags(); }); - selfoss.dbOffline.reloadOnlineStats(); - selfoss.dbOffline.refreshStats(); + this.reloadOnlineStats(); + this.refreshStats(); }) .catch(() => { selfoss.db.broken = true; selfoss.db.enableOffline.update(false); }); - }, + } _memLastItemId() { return selfoss.db.storage.entries @@ -146,28 +141,28 @@ selfoss.dbOffline = { .reverse() .first((entry) => { if (entry) { - selfoss.dbOffline.lastItemId = entry.id; + this.lastItemId = entry.id; } else { - selfoss.dbOffline.lastItemId = 0; + this.lastItemId = 0; } }); - }, + } storeEntries(entries) { - return selfoss.dbOffline._tr( + return this._tr( 'rw', [selfoss.db.storage.entries, selfoss.db.storage.stamps], () => { - selfoss.dbOffline.GCEntries(); + this.GCEntries(); // store entries offline selfoss.db.storage.entries.bulkPut(entries).then(() => { - selfoss.dbOffline._memLastItemId(); - selfoss.dbOffline.refreshStats(); + this._memLastItemId(); + this.refreshStats(); }); }, ); - }, + } GCEntries(more = false) { if (more) { @@ -175,16 +170,13 @@ selfoss.dbOffline = { // seems to be exceeded: decrease the amount of days entries are // kept offline. const keptDays = Math.floor( - (new Date() - selfoss.dbOffline.newestGCedEntry) / 86400000, + (new Date() - this.newestGCedEntry) / 86400000, ); - selfoss.dbOffline.offlineDays = Math.max( - Math.min(keptDays - 1, selfoss.dbOffline.offlineDays - 1), + this.offlineDays = Math.max( + Math.min(keptDays - 1, this.offlineDays - 1), 0, ); - window.localStorage.setItem( - 'offlineDays', - selfoss.dbOffline.offlineDays, - ); + window.localStorage.setItem('offlineDays', this.offlineDays); } return selfoss.db.storage.transaction( @@ -204,11 +196,7 @@ selfoss.dbOffline = { // Cleanup items older than offlineDays days, not of // interest. const limit = new Date( - Date.now() - - selfoss.dbOffline.offlineDays * - 24 * - 3600 * - 1000, + Date.now() - this.offlineDays * 24 * 3600 * 1000, ); selfoss.db.storage.entries @@ -219,12 +207,8 @@ selfoss.dbOffline = { }) .each((entry) => { selfoss.db.storage.entries.delete(entry.id); - if ( - selfoss.dbOffline.newestGCedEntry < - entry.datetime - ) { - selfoss.dbOffline.newestGCedEntry = - entry.datetime; + if (this.newestGCedEntry < entry.datetime) { + this.newestGCedEntry = entry.datetime; } }) .then(() => { @@ -235,8 +219,7 @@ selfoss.dbOffline = { }, { name: 'newestGCedEntry', - datetime: - selfoss.dbOffline.newestGCedEntry, + datetime: this.newestGCedEntry, }, ]); }); @@ -244,10 +227,10 @@ selfoss.dbOffline = { }); }, ); - }, + } storeStats(stats) { - return selfoss.dbOffline._tr('rw', [selfoss.db.storage.stats], () => { + return this._tr('rw', [selfoss.db.storage.stats], () => { for (const [name, value] of Object.entries(stats)) { selfoss.db.storage.stats.put({ name, @@ -255,10 +238,10 @@ selfoss.dbOffline = { }); } }); - }, + } storeLastUpdate(lastUpdate: Date): Promise { - return selfoss.dbOffline._tr('rw', [selfoss.db.storage.stamps], () => { + return this._tr('rw', [selfoss.db.storage.stamps], () => { if (lastUpdate) { selfoss.db.storage.stamps.put({ name: 'lastItemsUpdate', @@ -266,7 +249,7 @@ selfoss.dbOffline = { }); } }); - }, + } getEntries(fetchParams: FetchParams) { let hasMore = false; @@ -331,11 +314,11 @@ selfoss.dbOffline = { if ( !ascOrder && !alwaysInDb && - entry.datetime < selfoss.dbOffline.newestGCedEntry + entry.datetime < this.newestGCedEntry ) { // the offline db is missing older entries, the next // seek will have to find them online. - selfoss.dbOffline.olderEntriesOnline = true; + this.olderEntriesOnline = true; hasMore = true; return true; // stop iteration } @@ -352,10 +335,10 @@ selfoss.dbOffline = { }) .then((entriesCollection) => entriesCollection.toArray()) .then((entries) => ({ entries, hasMore })); - }, + } reloadOnlineStats() { - return selfoss.dbOffline._tr('r', [selfoss.db.storage.stats], () => { + return this._tr('r', [selfoss.db.storage.stats], () => { selfoss.db.storage.stats.toArray((stats) => { const newStats = {}; stats.forEach((stat) => { @@ -368,10 +351,10 @@ selfoss.dbOffline = { ); }); }); - }, + } refreshStats() { - return selfoss.dbOffline._tr('r', [selfoss.db.storage.entries], () => { + return this._tr('r', [selfoss.db.storage.entries], () => { const offlineCounts = { newest: 0, unread: 0, starred: 0 }; // IDBKeyRange does not support boolean indexes, so we need to @@ -390,11 +373,11 @@ selfoss.dbOffline = { selfoss.app.refreshOfflineCounts(offlineCounts); }); }); - }, + } enqueueStatuses(statuses) { if (statuses) { - selfoss.dbOffline.needsSync = true; + this.needsSync = true; } const d = new Date(); @@ -405,20 +388,20 @@ selfoss.dbOffline = { datetime: d, })); - return selfoss.dbOffline._tr('rw', [selfoss.db.storage.statusq], () => { + return this._tr('rw', [selfoss.db.storage.statusq], () => { selfoss.db.storage.statusq.bulkAdd(newQueuedStatuses); }); - }, + } enqueueStatus(entryId, statusName, statusValue) { - return selfoss.dbOffline.enqueueStatuses([ + return this.enqueueStatuses([ { entryId, name: statusName, value: statusValue, }, ]); - }, + } sendNewStatuses() { selfoss.db.storage.statusq @@ -437,12 +420,12 @@ selfoss.dbOffline = { .then((statuses) => { const s = statuses.length > 0 ? statuses : undefined; selfoss.dbOnline.sync(s, true).then(() => { - selfoss.dbOffline.needsSync = false; + this.needsSync = false; }); }); return selfoss.dbOnline._syncBegin(); - }, + } storeEntryStatuses(itemStatuses, dequeue = false, updateStats = true) { return selfoss.dbOffline @@ -486,7 +469,7 @@ selfoss.dbOffline = { () => { // the key was not found, the status of an entry // missing in db was updated, request sync. - selfoss.dbOffline.needsSync = true; + this.needsSync = true; }, ); @@ -511,27 +494,27 @@ selfoss.dbOffline = { } }, ) - .then(selfoss.dbOffline.refreshStats); - }, + .then(this.refreshStats); + } entriesMark(itemIds, unread) { selfoss.dbOnline.statsDirty = true; const newStatuses = itemIds.map((itemId) => { return { id: itemId, unread }; }); - return selfoss.dbOffline.storeEntryStatuses(newStatuses); - }, + return this.storeEntryStatuses(newStatuses); + } entryMark(itemId, unread) { - return selfoss.dbOffline.entriesMark([itemId], unread); - }, + return this.entriesMark([itemId], unread); + } entryStar(itemId, starred) { - return selfoss.dbOffline.storeEntryStatuses([ + return this.storeEntryStatuses([ { id: itemId, starred, }, ]); - }, -}; + } +} diff --git a/client/js/selfoss-db-online.ts b/client/js/selfoss-db-online.ts index 149c801c3..7b6997846 100644 --- a/client/js/selfoss-db-online.ts +++ b/client/js/selfoss-db-online.ts @@ -8,85 +8,94 @@ export type FetchParams = { type: FilterType; }; -selfoss.dbOnline = { - syncing: { +export default class DbOnline { + public syncing: { + promise: Promise | null; + request: { + promise: Promise; + controller: AbortController; + } | null; + resolve: () => void | null; + reject: () => void | null; + } = { promise: null, request: null, resolve: null, reject: null, - }, - statsDirty: false, - firstSync: true, + }; + public statsDirty: boolean = false; + public firstSync: boolean = true; _syncBegin() { - if (!selfoss.dbOnline.syncing.promise) { - selfoss.dbOnline.syncing.promise = new Promise( - (resolve, reject) => { - selfoss.dbOnline.syncing.resolve = resolve; - selfoss.dbOnline.syncing.reject = reject; - const monitor = window.setInterval(() => { - let stopChecking = false; - if (selfoss.dbOnline.syncing.promise) { - if (selfoss.db.userWaiting) { - // reject if user has been waiting for more than 10s, - // this means that connectivity is bad: user will get - // local content and server request will continue in - // the background. - reject(); - stopChecking = true; - } - } else { + if (!this.syncing.promise) { + this.syncing.promise = new Promise((resolve, reject) => { + this.syncing.resolve = resolve; + this.syncing.reject = reject; + const monitor = window.setInterval(() => { + let stopChecking = false; + if (this.syncing.promise) { + if (selfoss.db.userWaiting) { + // reject if user has been waiting for more than 10s, + // this means that connectivity is bad: user will get + // local content and server request will continue in + // the background. + reject(); stopChecking = true; } + } else { + stopChecking = true; + } - if (stopChecking) { - window.clearInterval(monitor); - } - }, 10000); - }, - ); + if (stopChecking) { + window.clearInterval(monitor); + } + }, 10000); + }); - selfoss.dbOnline.syncing.promise.finally(() => { - selfoss.dbOnline.syncing.promise = null; + this.syncing.promise.finally(() => { + this.syncing.promise = null; selfoss.db.userWaiting = false; }); } - return selfoss.dbOnline.syncing.promise; - }, + return this.syncing.promise; + } _syncDone(success: boolean = true): void { - if (selfoss.dbOnline.syncing.promise) { + if (this.syncing.promise) { if (success) { - selfoss.dbOnline.syncing.resolve(); + this.syncing.resolve(); } else { - const request = selfoss.dbOnline.syncing.request; - selfoss.dbOnline.syncing.reject(); + const request = this.syncing.request; + this.syncing.reject(); if (request) { request.controller.abort(); } } } - }, + } /** * sync server status. */ - sync(updatedStatuses, chained: boolean): Promise { - if (selfoss.dbOnline.syncing.promise && !chained) { + sync( + updatedStatuses: Array | undefined = undefined, + chained: boolean = false, + ): Promise { + if (this.syncing.promise && !chained) { if (updatedStatuses) { // Ensure the status queue is not cleared and gets sync'ed at // next sync. return Promise.reject(); } else { - return selfoss.dbOnline.syncing.promise; + return this.syncing.promise; } } - const syncing = selfoss.dbOnline._syncBegin(); + const syncing = this._syncBegin(); let getStatuses = true; - if (selfoss.db.lastUpdate === null || selfoss.dbOnline.firstSync) { + if (selfoss.db.lastUpdate === null || this.firstSync) { selfoss.db.lastUpdate = new Date(0); getStatuses = undefined; } @@ -108,19 +117,16 @@ selfoss.dbOnline = { syncParams.itemsHowMany = selfoss.config.itemsPerPage; } - selfoss.dbOnline.statsDirty = false; + this.statsDirty = false; - selfoss.dbOnline.syncing.request = itemsRequests.sync( - updatedStatuses, - syncParams, - ); + this.syncing.request = itemsRequests.sync(updatedStatuses, syncParams); - selfoss.dbOnline.syncing.request.promise + this.syncing.request.promise .then((data) => { selfoss.db.setOnline(); selfoss.db.lastSync = Date.now(); - selfoss.dbOnline.firstSync = false; + this.firstSync = false; const dataDate = data.lastUpdate; @@ -148,7 +154,7 @@ selfoss.dbOnline = { .storeEntries(data.newItems) .then(() => { selfoss.dbOffline.storeLastUpdate(dataDate); - selfoss.dbOnline._syncDone(); + this._syncDone(); }); } @@ -179,7 +185,7 @@ selfoss.dbOnline = { } } - if (!selfoss.dbOnline.statsDirty && 'stats' in data) { + if (!this.statsDirty && 'stats' in data) { selfoss.refreshStats( data.stats.total, data.stats.unread, @@ -237,11 +243,11 @@ selfoss.dbOnline = { selfoss.db.lastUpdate = dataDate; if (!storing) { - selfoss.dbOnline._syncDone(); + this._syncDone(); } }) .catch((error) => { - selfoss.dbOnline._syncDone(false); + this._syncDone(false); selfoss.handleAjaxError(error).catch((error) => { selfoss.app.showError( selfoss.app._('error_sync') + ' ' + error.message, @@ -249,13 +255,13 @@ selfoss.dbOnline = { }); }) .finally(() => { - if (selfoss.dbOnline.syncing.promise) { - selfoss.dbOnline.syncing.request = null; + if (this.syncing.promise) { + this.syncing.request = null; } }); return syncing; - }, + } /** * refresh current items. @@ -308,5 +314,5 @@ selfoss.dbOnline = { return selfoss.dbOffline.getEntries(fetchParams); }); }); - }, -}; + } +} diff --git a/client/js/selfoss-db.ts b/client/js/selfoss-db.ts index c9abf5de4..7a89566c8 100644 --- a/client/js/selfoss-db.ts +++ b/client/js/selfoss-db.ts @@ -12,39 +12,42 @@ import selfoss from './selfoss-base'; import { OfflineStorageNotAvailableError } from './errors'; import { ValueListenable } from './helpers/ValueListenable'; +import { OfflineDb } from './model/OfflineDb'; -selfoss.db = { +export default class Db { /** When an error occurs we disable the offline mode and mark the database as broken so it can be retried. */ - broken: false, - storage: null, - online: true, - enableOffline: new ValueListenable( + public broken: boolean = false; + public storage: OfflineDb | null = null; + public online: boolean = true; + public enableOffline: ValueListenable = new ValueListenable( window.localStorage.getItem('enableOffline') === 'true', - ), - userWaiting: true, + ); + public userWaiting: boolean = true; /** * last db timestamp known client side */ - lastUpdate: null, + public lastUpdate: Date | null = null; + + public lastSync: number | null = null; setOnline() { - if (!selfoss.db.online) { - selfoss.db.online = true; - selfoss.db.sync(); + if (!this.online) { + this.online = true; + this.sync(); selfoss.reloadTags(); selfoss.app.setOfflineState(false); } - }, + } - tryOnline(): Promise { - return selfoss.db.sync(true); - }, + tryOnline(): Promise { + return this.sync(true); + } setOffline(): Promise { - if (selfoss.db.storage && !selfoss.db.broken) { + if (this.storage && !this.broken) { selfoss.dbOnline._syncDone(false); - selfoss.db.online = false; + this.online = false; selfoss.app.setOfflineState(true); return Promise.resolve(); @@ -52,26 +55,26 @@ selfoss.db = { const err = new OfflineStorageNotAvailableError(); return Promise.reject(err); } - }, + } clear() { - if (selfoss.db.storage) { + if (this.storage) { window.localStorage.removeItem('offlineDays'); - const clearing = selfoss.db.storage.delete(); - selfoss.db.storage = null; - selfoss.db.lastUpdate = null; + const clearing = this.storage.delete(); + this.storage = null; + this.lastUpdate = null; return clearing; } else { return Promise.resolve(); } - }, + } isValidTag(name) { return ( selfoss.app.state.tags.length === 0 || selfoss.app.state.tags.find((tag) => tag.tag === name) !== undefined ); - }, + } isValidSource(id) { return ( @@ -79,19 +82,17 @@ selfoss.db = { selfoss.app.state.sources.find((source) => source.id === id) !== undefined ); - }, - - lastSync: null, + } sync(force = false) { const lastUpdateIsOld = - selfoss.db.lastUpdate === null || - selfoss.db.lastSync === null || - Date.now() - selfoss.db.lastSync > 5 * 60 * 1000; + this.lastUpdate === null || + this.lastSync === null || + Date.now() - this.lastSync > 5 * 60 * 1000; const shouldSync = force || selfoss.dbOffline.needsSync || lastUpdateIsOld; if (selfoss.isAllowedToRead() && selfoss.isOnline() && shouldSync) { - if (selfoss.db.enableOffline.value) { + if (this.enableOffline.value) { return selfoss.dbOffline.sendNewStatuses(); } else { return selfoss.dbOnline.sync(); @@ -99,5 +100,5 @@ selfoss.db = { } else { return Promise.resolve(); // ensure any chained function runs } - }, -}; + } +} From f4ed82218916ef2094dec4a1d97e30a933414996 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sat, 29 Jul 2023 17:30:00 +0200 Subject: [PATCH 10/19] client: Convert base to class Using static for now to minimize required changes --- client/js/helpers/authorizations.ts | 1 + client/js/index.ts | 9 +- client/js/selfoss-base.ts | 225 ++++++++++++++-------------- 3 files changed, 116 insertions(+), 119 deletions(-) diff --git a/client/js/helpers/authorizations.ts b/client/js/helpers/authorizations.ts index 25bf753e3..1a1e602e7 100644 --- a/client/js/helpers/authorizations.ts +++ b/client/js/helpers/authorizations.ts @@ -1,5 +1,6 @@ import { useListenableValue } from './hooks'; import { useMemo } from 'react'; +import selfoss from '../selfoss-base'; export function useLoggedIn(): boolean { return useListenableValue(selfoss.loggedin); diff --git a/client/js/index.ts b/client/js/index.ts index 046531320..70cc5c759 100644 --- a/client/js/index.ts +++ b/client/js/index.ts @@ -1,10 +1,7 @@ import 'regenerator-runtime/runtime'; -import selfoss from './selfoss-base'; -import './selfoss-db-online'; -import './selfoss-db-offline'; -import './selfoss-db'; +import base from './selfoss-base'; -selfoss.init(); +base.init(); declare global { interface Window { @@ -13,4 +10,4 @@ declare global { } // make selfoss available in console for debugging -window.selfoss = selfoss; +window.selfoss = base; diff --git a/client/js/selfoss-base.ts b/client/js/selfoss-base.ts index cb063488c..96738cc07 100644 --- a/client/js/selfoss-base.ts +++ b/client/js/selfoss-base.ts @@ -18,40 +18,39 @@ import Db from './selfoss-db'; * @copyright Copyright (c) Tobias Zeising (http://www.aditu.de) * @license GPLv3 (https://www.gnu.org/licenses/gpl-3.0.html) */ -const selfoss = { +class selfoss { /** * The main App component. - * @var App */ - app: null, + public static app: App | null = null; /** * React component for entries page. */ - entriesPage: null, + public static entriesPage = null; - serviceWorkerInitialized: false, + private static serviceWorkerInitialized = false; /** * Whether lightbox is open. */ - lightboxActive: new ValueListenable(false), + public static lightboxActive = new ValueListenable(false); - db: new Db(), - dbOnline: new DbOnline(), - dbOffline: new DbOffline(), + public static db: Db = new Db(); + public static dbOnline: DbOnline = new DbOnline(); + public static dbOffline: DbOffline = new DbOffline(); /** * initialize application */ - async init(): Promise { + static async init(): Promise { // Load off-line mode enabledness. - selfoss.db.enableOffline.update( + this.db.enableOffline.update( window.localStorage.getItem('enableOffline') === 'true', ); // Ignore stored config when off-line mode is disabled, since it is likely stale. - const storedConfig = selfoss.db.enableOffline.value + const storedConfig = this.db.enableOffline.value ? localStorage.getItem('configuration') : null; let oldConfiguration = null; @@ -85,19 +84,19 @@ const selfoss = { } } finally { if (configurationToUse) { - await selfoss.initMain(configurationToUse); + await this.initMain(configurationToUse); } else { // TODO: Add a more proper error page - document.body.innerHTML = selfoss.app._('error_configuration'); + document.body.innerHTML = this.app._('error_configuration'); } } - }, + } - async initMain(configuration: Configuration): Promise { - selfoss.config = configuration; + static async initMain(configuration: Configuration): Promise { + this.config = configuration; - if (selfoss.db.enableOffline.value) { - selfoss.setupServiceWorker(); + if (this.db.enableOffline.value) { + this.setupServiceWorker(); } if (configuration.language !== null) { @@ -130,21 +129,21 @@ const selfoss = { } // init offline if supported - selfoss.dbOffline.init(); + this.dbOffline.init(); if (configuration.authEnabled) { - selfoss.loggedin.update( + this.loggedin.update( window.localStorage.getItem('onlineSession') == 'true', ); } - selfoss.attachApp(configuration); - }, + this.attachApp(configuration); + } /** * Create basic DOM structure of the page. */ - attachApp(configuration: Configuration): void { + static attachApp(configuration: Configuration): void { document.getElementById('js-loading-message')?.remove(); const mainUi = document.createElement('div'); @@ -159,33 +158,33 @@ const selfoss = { createApp({ basePath, appRef: (app: App) => { - selfoss.app = app; + this.app = app; }, configuration, }), ); - }, + } - loggedin: new ValueListenable(false), + public static loggedin = new ValueListenable(false); - setSession(): void { + static setSession(): void { window.localStorage.setItem('onlineSession', 'true'); - selfoss.loggedin.update(true); - }, + this.loggedin.update(true); + } - clearSession(): void { + static clearSession(): void { window.localStorage.removeItem('onlineSession'); - selfoss.loggedin.update(false); - }, + this.loggedin.update(false); + } - hasSession(): boolean { - return selfoss.loggedin.value; - }, + static hasSession(): boolean { + return this.loggedin.value; + } /** * Try to log in using given credentials */ - login(props: { + static login(props: { configuration: Configuration; username: string; password: string; @@ -193,13 +192,13 @@ const selfoss = { }): Promise { const { configuration, username, password, enableOffline } = props; - selfoss.db.enableOffline.update(enableOffline); + this.db.enableOffline.update(enableOffline); window.localStorage.setItem( 'enableOffline', - selfoss.db.enableOffline.value, + this.db.enableOffline.value, ); - if (!selfoss.db.enableOffline.value) { - selfoss.db.clear(); + if (!this.db.enableOffline.value) { + this.db.clear(); } const credentials = { @@ -207,15 +206,15 @@ const selfoss = { password, }; return login(credentials).then(() => { - selfoss.setSession(); + this.setSession(); // init offline if supported and not inited yet - selfoss.dbOffline.init(); + this.dbOffline.init(); if ( - (!selfoss.db.storage || selfoss.db.broken) && - selfoss.db.enableOffline.value + (!this.db.storage || this.db.broken) && + this.db.enableOffline.value ) { // Initialize database in offline mode when it has not been initialized yet or it got broken. - selfoss.dbOffline.init(); + this.dbOffline.init(); // Store config for off-line use. localStorage.setItem( @@ -237,20 +236,17 @@ const selfoss = { ); } - selfoss.setupServiceWorker(); + this.setupServiceWorker(); } }); - }, + } - setupServiceWorker(): void { - if ( - !('serviceWorker' in navigator) || - selfoss.serviceWorkerInitialized - ) { + static setupServiceWorker(): void { + if (!('serviceWorker' in navigator) || this.serviceWorkerInitialized) { return; } - selfoss.serviceWorkerInitialized = true; + this.serviceWorkerInitialized = true; navigator.serviceWorker.addEventListener('controllerchange', () => { window.location.reload(); @@ -261,20 +257,20 @@ const selfoss = { type: 'module', }) .then((reg) => { - selfoss.listenWaitingSW(reg, (reg) => { - selfoss.app.notifyNewVersion(() => { + this.listenWaitingSW(reg, (reg) => { + this.app.notifyNewVersion(() => { if (reg.waiting) { reg.waiting.postMessage('skipWaiting'); } }); }); }); - }, + } - async logout(): Promise { - selfoss.clearSession(); + static async logout(): Promise { + this.clearSession(); - selfoss.db.clear(); // will not work after a failure, since storage is nulled + this.db.clear(); // will not work after a failure, since storage is nulled window.localStorage.clear(); if ('serviceWorker' in navigator) { if ('caches' in window) { @@ -288,108 +284,108 @@ const selfoss = { reg.unregister(); }); }); - selfoss.serviceWorkerInitialized = false; + this.serviceWorkerInitialized = false; } try { await logout(); - if (!selfoss.config.publicMode) { + if (!this.config.publicMode) { selfoss.navigate('/sign/in'); } } catch (error) { - selfoss.app.showError( - selfoss.app._('error_logout') + ' ' + error.message, + this.app.showError( + this.app._('error_logout') + ' ' + error.message, ); } - }, + } /** * Checks whether the current user is allowed to perform read operations. */ - isAllowedToRead(): boolean { + static isAllowedToRead(): boolean { return ( - selfoss.hasSession() || - !selfoss.config.authEnabled || - selfoss.config.publicMode + this.hasSession() || + !this.config.authEnabled || + this.config.publicMode ); - }, + } /** * Checks whether the current user is allowed to perform update-tier operations. */ - isAllowedToUpdate(): boolean { + static isAllowedToUpdate(): boolean { return ( - selfoss.hasSession() || - !selfoss.config.authEnabled || - selfoss.config.allowPublicUpdate + this.hasSession() || + !this.config.authEnabled || + this.config.allowPublicUpdate ); - }, + } /** * Checks whether the current user is allowed to perform write operations. */ - isAllowedToWrite(): boolean { - return selfoss.hasSession() || !selfoss.config.authEnabled; - }, + static isAllowedToWrite(): boolean { + return this.hasSession() || !this.config.authEnabled; + } /** * Checks whether the current user is allowed to perform write operations. */ - isOnline(): boolean { - return selfoss.db.online; - }, + static isOnline(): boolean { + return this.db.online; + } /** * indicates whether a mobile device is host * * @return true if device resolution smaller equals 1024 */ - isMobile(): boolean { + static isMobile(): boolean { // first check useragent if (/iPhone|iPod|iPad|Android|BlackBerry/.test(navigator.userAgent)) { return true; } // otherwise check resolution - return selfoss.isTablet() || selfoss.isSmartphone(); - }, + return this.isTablet() || this.isSmartphone(); + } /** * indicates whether a tablet is the device or not * * @return true if device resolution smaller equals 1024 */ - isTablet(): boolean { + static isTablet(): boolean { if (document.body.clientWidth <= 1024) { return true; } return false; - }, + } /** * indicates whether a tablet is the device or not * * @return true if device resolution smaller equals 1024 */ - isSmartphone(): boolean { + static isSmartphone(): boolean { if (document.body.clientWidth <= 640) { return true; } return false; - }, + } /** * Override these functions to customize selfoss behaviour. */ - extensionPoints: { + public static extensionPoints = { /** * Called when an article is first expanded. * @param _contents HTML element containing the article contents */ // eslint-disable-next-line @typescript-eslint/no-unused-vars processItemContents(_contents: HTMLElement) {}, - }, + }; /** * refresh stats. @@ -398,42 +394,45 @@ const selfoss = { * @param unread new unread stats * @param starred new starred stats */ - refreshStats(all: number, unread: number, starred: number): void { - selfoss.app.setAllItemsCount(all); - selfoss.app.setStarredItemsCount(starred); + static refreshStats(all: number, unread: number, starred: number): void { + this.app.setAllItemsCount(all); + this.app.setStarredItemsCount(starred); - selfoss.refreshUnread(unread); - }, + this.refreshUnread(unread); + } /** * refresh unread stats. * * @param unread new unread stats */ - refreshUnread(unread: number): void { - selfoss.app.setUnreadItemsCount(unread); - }, + static refreshUnread(unread: number): void { + this.app.setUnreadItemsCount(unread); + } /** * refresh current tags. */ - reloadTags(): void { - selfoss.app.setTagsState(LoadingState.LOADING); + static reloadTags(): void { + this.app.setTagsState(LoadingState.LOADING); getAllTags() .then((data) => { - selfoss.app.setTags(data); - selfoss.app.setTagsState(LoadingState.SUCCESS); + this.app.setTags(data); + this.app.setTagsState(LoadingState.SUCCESS); }) .catch((error) => { - selfoss.app.setTagsState(LoadingState.FAILURE); - selfoss.app.showError( - selfoss.app._('error_load_tags') + ' ' + error.message, + this.app.setTagsState(LoadingState.FAILURE); + this.app.showError( + this.app._('error_load_tags') + ' ' + error.message, ); }); - }, + } - handleAjaxError(error: Error, tryOffline: boolean = true): Promise { + static handleAjaxError( + error: Error, + tryOffline: boolean = true, + ): Promise { if (!(error instanceof HttpError || error instanceof TimeoutError)) { return Promise.reject(error); } @@ -441,13 +440,13 @@ const selfoss = { const httpCode = error?.response?.status || 0; if (tryOffline && httpCode != 403) { - return selfoss.db.setOffline(); + return this.db.setOffline(); } else { return Promise.reject(error); } - }, + } - listenWaitingSW( + static listenWaitingSW( reg: ServiceWorkerRegistration, callback: (reg: ServiceWorkerRegistration) => void, ): void { @@ -467,10 +466,10 @@ const selfoss = { awaitStateChange(); reg.addEventListener('updatefound', awaitStateChange); } - }, + } // Include helpers for user scripts. - ajax, -}; + public static ajax = ajax; +} export default selfoss; From dc1fa990261ef1cdfecd02b34ad72a0bad488a35 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 30 Jul 2023 15:34:37 +0200 Subject: [PATCH 11/19] client: Fix return type --- client/js/sharers.tsx | 2 +- client/js/templates/App.tsx | 10 +++++----- client/js/templates/Navigation.tsx | 2 +- client/js/templates/OpmlImport.tsx | 4 ++-- client/js/templates/SourceParam.tsx | 4 +++- client/js/templates/SourcesPage.tsx | 2 +- client/js/templates/Spinner.tsx | 4 ++-- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/client/js/sharers.tsx b/client/js/sharers.tsx index b517145cc..1ee7aeec2 100644 --- a/client/js/sharers.tsx +++ b/client/js/sharers.tsx @@ -7,7 +7,7 @@ import { Configuration } from './model/Configuration'; type Sharer = { label: string; - icon: string | JSX.Element; + icon: string | React.JSX.Element; action: (params: { url: string; title: string }) => void; available?: boolean; }; diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index 311f1a14e..0723be297 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -67,7 +67,7 @@ type MessageProps = { * It watches globalMessage and updates/shows/hides itself as necessary * when the value changes. */ -function Message(props: MessageProps): JSX.Element | null { +function Message(props: MessageProps): React.JSX.Element | null { const { message } = props; // Whenever message changes, dismiss it after 15 seconds. @@ -100,7 +100,7 @@ function Message(props: MessageProps): JSX.Element | null { ) : null; } -function NotFound(): JSX.Element { +function NotFound(): React.JSX.Element { const location = useLocation(); const _ = useContext(LocalizationContext); return

{_('error_invalid_subsection') + location.pathname}

; @@ -195,7 +195,7 @@ type PureAppProps = { reloadAll: () => void; }; -function PureApp(props: PureAppProps): JSX.Element { +function PureApp(props: PureAppProps): React.JSX.Element { const { navSourcesExpanded, setNavSourcesExpanded, @@ -861,7 +861,7 @@ export class App extends React.Component { } } - render(): JSX.Element { + render(): React.JSX.Element { return ( @@ -901,7 +901,7 @@ export function createApp(args: { basePath: string; appRef: React.Ref; configuration: Configuration; -}): JSX.Element { +}): React.JSX.Element { const { basePath, appRef, configuration } = args; return ( diff --git a/client/js/templates/Navigation.tsx b/client/js/templates/Navigation.tsx index 0c58b0a7a..522533485 100644 --- a/client/js/templates/Navigation.tsx +++ b/client/js/templates/Navigation.tsx @@ -33,7 +33,7 @@ type NavigationProps = { reloadAll: React.Dispatch>>; }; -export default function Navigation(props: NavigationProps): JSX.Element { +export default function Navigation(props: NavigationProps): React.JSX.Element { const { entriesPage, setNavExpanded, diff --git a/client/js/templates/OpmlImport.tsx b/client/js/templates/OpmlImport.tsx index b4697f377..cb1ec9e91 100644 --- a/client/js/templates/OpmlImport.tsx +++ b/client/js/templates/OpmlImport.tsx @@ -9,11 +9,11 @@ type OpmlImportProps = { setTitle: (title: string | null) => void; }; -export default function OpmlImport(props: OpmlImportProps): JSX.Element { +export default function OpmlImport(props: OpmlImportProps): React.JSX.Element { const { setTitle } = props; const [state, setState] = useState(LoadingState.INITIAL); - const [message, setMessage] = useState(null); + const [message, setMessage] = useState(null); const fileEntry = useRef(null); const navigate = useNavigate(); diff --git a/client/js/templates/SourceParam.tsx b/client/js/templates/SourceParam.tsx index 28b73b021..255444859 100644 --- a/client/js/templates/SourceParam.tsx +++ b/client/js/templates/SourceParam.tsx @@ -11,7 +11,9 @@ type SourceParamProps = { setDirty: React.Dispatch>; }; -export default function SourceParam(props: SourceParamProps): JSX.Element { +export default function SourceParam( + props: SourceParamProps, +): React.JSX.Element { const { spoutParamName, spoutParam, diff --git a/client/js/templates/SourcesPage.tsx b/client/js/templates/SourcesPage.tsx index 50d53d5c7..91d6e8458 100644 --- a/client/js/templates/SourcesPage.tsx +++ b/client/js/templates/SourcesPage.tsx @@ -112,7 +112,7 @@ function loadSources({ }); } -export default function SourcesPage(): JSX.Element { +export default function SourcesPage(): React.JSX.Element { const [spouts, setSpouts] = useState([]); const [sources, setSources] = useState([]); diff --git a/client/js/templates/Spinner.tsx b/client/js/templates/Spinner.tsx index 1c46eaba9..58f1365be 100644 --- a/client/js/templates/Spinner.tsx +++ b/client/js/templates/Spinner.tsx @@ -8,7 +8,7 @@ type SpinnerProps = { size?: SizeProp; }; -export function Spinner(props: SpinnerProps): JSX.Element { +export function Spinner(props: SpinnerProps): React.JSX.Element { const { label, size } = props; return ( @@ -31,7 +31,7 @@ type SpinnerBigProps = { label: string; }; -export function SpinnerBig(props: SpinnerBigProps): JSX.Element { +export function SpinnerBig(props: SpinnerBigProps): React.JSX.Element { const { label } = props; return ( From 1fb1b9bb80866d4e7623c2ad3c849a7945a41358 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Mon, 31 Jul 2023 22:06:55 +0200 Subject: [PATCH 12/19] client: do not rely on selfoss from `window` so that tsc can find it. --- client/js/shortcuts.ts | 1 + client/js/templates/App.tsx | 1 + client/js/templates/EntriesPage.tsx | 1 + client/js/templates/Item.tsx | 1 + client/js/templates/LoginForm.tsx | 1 + client/js/templates/NavSearch.tsx | 1 + client/js/templates/NavSources.tsx | 1 + client/js/templates/NavTags.tsx | 1 + client/js/templates/NavToolBar.tsx | 1 + client/js/templates/Source.tsx | 1 + client/js/templates/SourcesPage.tsx | 2 ++ 11 files changed, 12 insertions(+) diff --git a/client/js/shortcuts.ts b/client/js/shortcuts.ts index 4ed7a6bc2..0c96dce78 100644 --- a/client/js/shortcuts.ts +++ b/client/js/shortcuts.ts @@ -1,4 +1,5 @@ import { tinykeys } from 'tinykeys'; +import selfoss from './selfoss-base'; import { Direction } from './helpers/navigation'; type KeyboardEventHandler = (event: KeyboardEvent) => void; diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index 0723be297..6a2e2ae8e 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -11,6 +11,7 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Collapse } from '@kunukn/react-collapse'; import classNames from 'classnames'; +import selfoss from '../selfoss-base'; import HashPassword from './HashPassword'; import OpmlImport from './OpmlImport'; import LoginForm from './LoginForm'; diff --git a/client/js/templates/EntriesPage.tsx b/client/js/templates/EntriesPage.tsx index aa596021a..ae091f4d2 100644 --- a/client/js/templates/EntriesPage.tsx +++ b/client/js/templates/EntriesPage.tsx @@ -9,6 +9,7 @@ import React, { import { Link, NavigateFunction, useLocation, useParams } from 'react-router'; import { useOnline } from 'rooks'; import { useStateWithDeps } from 'use-state-with-deps'; +import selfoss from '../selfoss-base'; import Item from './Item'; import { FilterType } from '../Filter'; import * as itemsRequests from '../requests/items'; diff --git a/client/js/templates/Item.tsx b/client/js/templates/Item.tsx index 94c0e106f..62dd04b26 100644 --- a/client/js/templates/Item.tsx +++ b/client/js/templates/Item.tsx @@ -11,6 +11,7 @@ import { usePreviousImmediate } from 'rooks'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import { createFocusTrap } from 'focus-trap'; +import selfoss from '../selfoss-base'; import { useAllowedToWrite } from '../helpers/authorizations'; import { useForceReload, diff --git a/client/js/templates/LoginForm.tsx b/client/js/templates/LoginForm.tsx index d19f68665..e224327f3 100644 --- a/client/js/templates/LoginForm.tsx +++ b/client/js/templates/LoginForm.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useState } from 'react'; import classNames from 'classnames'; import { History } from 'history'; +import selfoss from '../selfoss-base'; import { SpinnerBig } from './Spinner'; import { useLocation, useNavigate } from 'react-router'; import { Configuration } from '../model/Configuration'; diff --git a/client/js/templates/NavSearch.tsx b/client/js/templates/NavSearch.tsx index 542c32280..2c7b489c0 100644 --- a/client/js/templates/NavSearch.tsx +++ b/client/js/templates/NavSearch.tsx @@ -8,6 +8,7 @@ import React, { import { useLocation, useNavigate } from 'react-router'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import selfoss from '../selfoss-base'; import { makeEntriesLink } from '../helpers/uri'; import * as icons from '../icons'; import { LocalizationContext } from '../helpers/i18n'; diff --git a/client/js/templates/NavSources.tsx b/client/js/templates/NavSources.tsx index f325df65a..a1d7c7ca2 100644 --- a/client/js/templates/NavSources.tsx +++ b/client/js/templates/NavSources.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router'; import { usePreviousImmediate } from 'rooks'; import classNames from 'classnames'; import { unescape } from 'html-escaper'; +import selfoss from '../selfoss-base'; import { useForceReload, makeEntriesLinkLocation, diff --git a/client/js/templates/NavTags.tsx b/client/js/templates/NavTags.tsx index d24eb3438..e26d6f8d5 100644 --- a/client/js/templates/NavTags.tsx +++ b/client/js/templates/NavTags.tsx @@ -2,6 +2,7 @@ import React, { useContext, useMemo, useCallback, useState } from 'react'; import { Link, useLocation } from 'react-router'; import classNames from 'classnames'; import { unescape } from 'html-escaper'; +import selfoss from '../selfoss-base'; import { useForceReload, makeEntriesLinkLocation, diff --git a/client/js/templates/NavToolBar.tsx b/client/js/templates/NavToolBar.tsx index bebd02791..2310146dc 100644 --- a/client/js/templates/NavToolBar.tsx +++ b/client/js/templates/NavToolBar.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useState } from 'react'; import { Link } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import selfoss from '../selfoss-base'; import * as icons from '../icons'; import { useAllowedToUpdate, diff --git a/client/js/templates/Source.tsx b/client/js/templates/Source.tsx index 59b9e4f54..172d45096 100644 --- a/client/js/templates/Source.tsx +++ b/client/js/templates/Source.tsx @@ -7,6 +7,7 @@ import { makeEntriesLinkLocation } from '../helpers/uri'; import { unescape } from 'html-escaper'; import classNames from 'classnames'; import { pick } from 'lodash-es'; +import selfoss from '../selfoss-base'; import SourceParam from './SourceParam'; import { Spinner } from './Spinner'; import * as sourceRequests from '../requests/sources'; diff --git a/client/js/templates/SourcesPage.tsx b/client/js/templates/SourcesPage.tsx index 91d6e8458..5cc6e1d49 100644 --- a/client/js/templates/SourcesPage.tsx +++ b/client/js/templates/SourcesPage.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useMemo } from 'react'; import { Link, useNavigate, useLocation, useMatch } from 'react-router'; +import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom'; +import selfoss from '../selfoss-base'; import Source from './Source'; import { SpinnerBig } from './Spinner'; import { LoadingState } from '../requests/LoadingState'; From cb64e98a7ccc7e5af770717cf0aa6a35a96131c2 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 6 Aug 2023 23:04:00 +0200 Subject: [PATCH 13/19] client: Fix more type check issues --- client/js/helpers/i18n.ts | 2 +- client/js/templates/Source.tsx | 47 ++++++++++++++++++++++++----- client/js/templates/SourceParam.tsx | 9 +++--- client/js/templates/SourcesPage.tsx | 4 ++- client/package-lock.json | 26 ++++++++++++++++ client/package.json | 1 + 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/client/js/helpers/i18n.ts b/client/js/helpers/i18n.ts index 12b8a9ed0..016f210fc 100644 --- a/client/js/helpers/i18n.ts +++ b/client/js/helpers/i18n.ts @@ -107,4 +107,4 @@ export function i18nFormat( return formatted; } -export const LocalizationContext = React.createContext(); +export const LocalizationContext = React.createContext(undefined); diff --git a/client/js/templates/Source.tsx b/client/js/templates/Source.tsx index 172d45096..9c16cc04f 100644 --- a/client/js/templates/Source.tsx +++ b/client/js/templates/Source.tsx @@ -252,12 +252,43 @@ function daysAgo(date) { return Math.floor((today - old) / MS_PER_DAY); } +export type SpoutParam = { + title: string; + default: string; +} & ( + | { + type: 'text' | 'url' | 'password' | 'checkbox'; + } + | { + type: 'select'; + values: { [s: string]: string }; + } +); + +type Spout = { + name: string; + description: string; + params: { [name: string]: SpoutParam }; +}; + +export type Source = { + id: number; + title: string; + spout: string; + tags: string; + filter: string; + params: { [name: string]: string }; + icon: string; + lastentry: number; + error: string; +}; + type SourceEditFormProps = { - source: object; + source: Source; sourceElem: object; sourceError?: string; setSources: React.Dispatch>>; - spouts: object; + spouts: { [className: string]: Spout }; setSpouts: React.Dispatch>>; setEditedSource: React.Dispatch>; sourceActionLoading: boolean; @@ -560,12 +591,14 @@ function SourceEditForm(props: SourceEditFormProps) { } type SourceProps = { - source: object; - setSources: React.Dispatch>>; - spouts: object; + source: Source; + setSources: React.Dispatch>>; + spouts: { [className: string]: Spout }; setSpouts: React.Dispatch>; dirty: boolean; - setDirtySources: React.Dispatch>; + setDirtySources: React.Dispatch< + React.SetStateAction<{ [id: number]: boolean }> + >; }; export default function Source(props: SourceProps) { @@ -620,7 +653,7 @@ export default function Source(props: SourceProps) { const sourceElem = useRef(null); const extraMenuOnSelection = useCallback( - ({ value }) => { + ({ value }: { value?: string }) => { if (value === 'delete') { handleDelete({ source, diff --git a/client/js/templates/SourceParam.tsx b/client/js/templates/SourceParam.tsx index 255444859..354325c3b 100644 --- a/client/js/templates/SourceParam.tsx +++ b/client/js/templates/SourceParam.tsx @@ -1,13 +1,14 @@ import React, { useCallback, useContext } from 'react'; import { LocalizationContext } from '../helpers/i18n'; +import { Source, SpoutParam } from './Source'; type SourceParamProps = { spoutParamName: string; - spoutParam: object; - params: object; + spoutParam: SpoutParam; + params: { [index: string]: string }; sourceErrors: { [index: string]: string }; sourceId: number; - setEditedSource: React.Dispatch>; + setEditedSource: React.Dispatch>; setDirty: React.Dispatch>; }; @@ -93,7 +94,7 @@ export default function SourceParam(