From 3b5020bf7f6f3f5bcb040c368b95ccdd2d297f7b Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Fri, 31 May 2024 07:49:03 +0200 Subject: [PATCH 01/18] client: Use prettier for formatting I do not like the formatting very much but at least it is consistent and supports TypeScript. https://typescript-eslint.io/troubleshooting/formatting/ Also replaces `lint:js` npm script in the `client/` directory with `check:js:lint` and adds `check:js` which runs that in addition to newly introduced `check:js:prettify`. This matches how the stylesheet scripts are structured. We are using `eslint-config-prettier` instead of `eslint-plugin-prettier`, the latter would run prettier as part of eslint but we are already running it separately. TODO: Remove stylistic rules from config. --- client/.eslintrc.json | 3 +- client/images/wallabag.js | 2 +- client/js/Filter.js | 2 +- client/js/helpers/ajax.js | 186 +++++---- client/js/helpers/authorizations.js | 16 +- client/js/helpers/color.js | 8 +- client/js/helpers/i18n.js | 123 +++--- client/js/helpers/navigation.js | 4 +- client/js/helpers/uri.js | 12 +- client/js/icons.js | 4 +- client/js/locales.js | 52 +-- client/js/requests/LoadingState.js | 2 +- client/js/requests/common.js | 78 ++-- client/js/requests/items.js | 63 +-- client/js/requests/sources.js | 61 +-- client/js/requests/tags.js | 6 +- client/js/selfoss-base.js | 142 ++++--- client/js/selfoss-db-offline.js | 586 +++++++++++++++------------ client/js/selfoss-db-online.js | 330 ++++++++------- client/js/selfoss-db.js | 35 +- client/js/sharers.jsx | 254 +++++++----- client/js/shortcuts.js | 34 +- client/js/templates/App.jsx | 361 +++++++++-------- client/js/templates/ColorChooser.jsx | 109 +++-- client/js/templates/EntriesPage.jsx | 585 ++++++++++++++++---------- client/js/templates/HashPassword.jsx | 74 ++-- client/js/templates/Item.jsx | 364 +++++++++++------ client/js/templates/LoginForm.jsx | 77 ++-- client/js/templates/NavFilters.jsx | 169 ++++++-- client/js/templates/NavSearch.jsx | 40 +- client/js/templates/NavSources.jsx | 119 ++++-- client/js/templates/NavTags.jsx | 74 ++-- client/js/templates/NavToolBar.jsx | 48 +-- client/js/templates/Navigation.jsx | 45 +- client/js/templates/OpmlImport.jsx | 163 ++++---- client/js/templates/SearchList.jsx | 37 +- client/js/templates/Source.jsx | 248 ++++++------ client/js/templates/SourceParam.jsx | 21 +- client/js/templates/SourcesPage.jsx | 182 +++++---- client/js/templates/Spinner.jsx | 5 +- client/package-lock.json | 13 + client/package.json | 11 +- client/selfoss-sw-offline.js | 27 +- 43 files changed, 2798 insertions(+), 1977 deletions(-) diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 778f60de8f..3df0bc8883 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -3,7 +3,8 @@ "extends": [ "eslint:recommended", "plugin:react/recommended", - "plugin:react-hooks/recommended" + "plugin:react-hooks/recommended", + "prettier" ], "parserOptions": { "ecmaVersion": "latest", diff --git a/client/images/wallabag.js b/client/images/wallabag.js index a7f3a313dd..acd87bb8ac 100644 --- a/client/images/wallabag.js +++ b/client/images/wallabag.js @@ -4,5 +4,5 @@ export default [ [], null, // wallabag.svg: Free Art License 1.3 https://github.com/wallabag/logo - 'M108.69.004c-.241-.026-.512.083-.777.299-.572.465-5.551 1.614-8.504 3.917-4.768 3.72-7.707 10.796-9.04 14.708-.024.06-.205.603-.265.79-.62 1.499-1.857 1.495-1.857 1.495v.002c-.6-.065-1.202-.102-1.809-.102-.54 0-1.078.03-1.615.082-.012.002-.02 0-.031 0-1.581.233-2.451-1.696-2.633-2.156C80.312 13.735 75.342 3.277 64.174.463c0 0-2.028-1.554-1.41 1.074.588 2.51 1.805 5.048 1.535 8.74-.124 1.704-1.18 10.442 6.85 14.99.763.432 1.44.796 2.05 1.102-4.041 3.235-7.715 7.739-10.858 12.852 1.597-.981 10.206-5.557 24.097.177 14.29 5.897 23.155.777 24.254.08-3.454-5.678-7.561-10.62-12.103-13.943.303-.083.612-.168.939-.264 6.023-1.742 7.553-6.84 7.875-11.209.364-4.954.615-5.029 1.691-9.486.774-3.21.32-4.495-.404-4.572zM86.774 50.228a4.677 4.677 0 00-1.652.256 6.555 6.555 0 00-1.332.615 3.879 3.879 0 00-1.094.985c-.322.432-.486.901-.486 1.396v16.307c0 2.158-.362 3.75-1.078 4.73-.688.94-1.85 1.397-3.55 1.397-1.704 0-2.876-.46-3.583-1.402-.734-.98-1.108-2.57-1.108-4.725V53.73c0-.908-.383-1.727-1.144-2.437-.751-.702-1.75-1.059-2.973-1.059-1.258 0-2.297.352-3.086 1.045-.81.71-1.22 1.536-1.22 2.451v15.807c0 1.988.193 3.869.574 5.588.393 1.758 1.077 3.3 2.035 4.586.968 1.299 2.282 2.322 3.906 3.049 1.607.716 3.617 1.08 5.975 1.08 2.457 0 4.515-.457 6.115-1.356a10.678 10.678 0 003.371-2.95 10.256 10.256 0 003.295 2.95c1.58.9 3.669 1.354 6.21 1.354 2.358 0 4.358-.363 5.946-1.08 1.601-.726 2.903-1.752 3.873-3.05.96-1.29 1.644-2.832 2.033-4.585.381-1.72.577-3.6.577-5.588V53.73c0-.91-.398-1.733-1.184-2.445-.767-.697-1.82-1.05-3.121-1.05-1.181 0-2.161.356-2.912 1.058-.76.71-1.145 1.53-1.145 2.437v16.057c0 2.154-.381 3.742-1.134 4.72-.728.947-1.891 1.407-3.555 1.407-1.703 0-2.863-.458-3.549-1.397-.716-.979-1.078-2.57-1.078-4.73v-16.12c0-1.097-.501-1.997-1.45-2.597-.805-.507-1.607-.81-2.476-.842zM51.796 82.363a31.2 31.2 0 001.865 5.742c.666 3.745 1.561 12.563-2.674 20.282-3.731 6.8-22.15 16.069-49.484 10.748 0 0-1.096-.765-1.428-.135-.491.932 1.516 1.684 3.582 2.228 19.03 5.04 47.756 2.989 56.777-4.443 4.116-3.388 5.705-7.953 6.108-12.865l.002.008s.11-1.288 1.718-.32c.461.276 2.126 1.36 2.391 2.585.232 1.743.248 3.884-.652 5.383-1.287 2.144-1.302 2.45.392 3.66 1.04.742 5.289 3.865 11.2 7.416.015.01.022.02.037.028 1.25.753 2.988 2.595 2.988 2.595 2.662 3.08 8.45 9.277 10.97 8.11 1.19-.551-.05-3.033-.05-3.033s1.98 2.572 3.043 1.695c.809-.668-.473-3.23-.473-3.23s1.73 1.5 2.758.945c1.258-.68-.188-4.614-10.08-10.627-9.896-6.018-12.579-6.941-12.815-9.627 0 0-.004-.134.004-.365.077-.593.416-1.848 1.854-1.713 2.14.346 4.347.53 6.607.53 2.587 0 5.106-.236 7.535-.689l.002.002.164-.029c.284-.036.838-.019.84.67-.09.873-.331 1.752-.845 2.52-1.447 2.168-.971 2.466.54 3.859.934.859 5.212 4.622 11.07 8.264.013.009.016.016.03.023 1.25.752 3.41 2.814 3.41 2.814 2.428 2.466 6.895 6.595 9.328 6.346 1.646-.168.305-3.002.305-3.002s2.079 2.006 3.1 1.416c1.142-.659-.475-2.754-.475-2.754s1.338.708 2.283.473c.948-.236 1.187-2.643-8.654-8.736-9.842-6.098-13.154-8.244-12.947-10.577 0 0 .003-.379.1-.957.238-1.236.994-3.346 3.406-4.55.079-.04.147-.084.209-.13 7.668-4.45 13.27-11.614 15.246-20.56-1.99 4.941-16.737 8.78-34.647 8.78-17.903 0-32.65-3.839-34.64-8.78z' + 'M108.69.004c-.241-.026-.512.083-.777.299-.572.465-5.551 1.614-8.504 3.917-4.768 3.72-7.707 10.796-9.04 14.708-.024.06-.205.603-.265.79-.62 1.499-1.857 1.495-1.857 1.495v.002c-.6-.065-1.202-.102-1.809-.102-.54 0-1.078.03-1.615.082-.012.002-.02 0-.031 0-1.581.233-2.451-1.696-2.633-2.156C80.312 13.735 75.342 3.277 64.174.463c0 0-2.028-1.554-1.41 1.074.588 2.51 1.805 5.048 1.535 8.74-.124 1.704-1.18 10.442 6.85 14.99.763.432 1.44.796 2.05 1.102-4.041 3.235-7.715 7.739-10.858 12.852 1.597-.981 10.206-5.557 24.097.177 14.29 5.897 23.155.777 24.254.08-3.454-5.678-7.561-10.62-12.103-13.943.303-.083.612-.168.939-.264 6.023-1.742 7.553-6.84 7.875-11.209.364-4.954.615-5.029 1.691-9.486.774-3.21.32-4.495-.404-4.572zM86.774 50.228a4.677 4.677 0 00-1.652.256 6.555 6.555 0 00-1.332.615 3.879 3.879 0 00-1.094.985c-.322.432-.486.901-.486 1.396v16.307c0 2.158-.362 3.75-1.078 4.73-.688.94-1.85 1.397-3.55 1.397-1.704 0-2.876-.46-3.583-1.402-.734-.98-1.108-2.57-1.108-4.725V53.73c0-.908-.383-1.727-1.144-2.437-.751-.702-1.75-1.059-2.973-1.059-1.258 0-2.297.352-3.086 1.045-.81.71-1.22 1.536-1.22 2.451v15.807c0 1.988.193 3.869.574 5.588.393 1.758 1.077 3.3 2.035 4.586.968 1.299 2.282 2.322 3.906 3.049 1.607.716 3.617 1.08 5.975 1.08 2.457 0 4.515-.457 6.115-1.356a10.678 10.678 0 003.371-2.95 10.256 10.256 0 003.295 2.95c1.58.9 3.669 1.354 6.21 1.354 2.358 0 4.358-.363 5.946-1.08 1.601-.726 2.903-1.752 3.873-3.05.96-1.29 1.644-2.832 2.033-4.585.381-1.72.577-3.6.577-5.588V53.73c0-.91-.398-1.733-1.184-2.445-.767-.697-1.82-1.05-3.121-1.05-1.181 0-2.161.356-2.912 1.058-.76.71-1.145 1.53-1.145 2.437v16.057c0 2.154-.381 3.742-1.134 4.72-.728.947-1.891 1.407-3.555 1.407-1.703 0-2.863-.458-3.549-1.397-.716-.979-1.078-2.57-1.078-4.73v-16.12c0-1.097-.501-1.997-1.45-2.597-.805-.507-1.607-.81-2.476-.842zM51.796 82.363a31.2 31.2 0 001.865 5.742c.666 3.745 1.561 12.563-2.674 20.282-3.731 6.8-22.15 16.069-49.484 10.748 0 0-1.096-.765-1.428-.135-.491.932 1.516 1.684 3.582 2.228 19.03 5.04 47.756 2.989 56.777-4.443 4.116-3.388 5.705-7.953 6.108-12.865l.002.008s.11-1.288 1.718-.32c.461.276 2.126 1.36 2.391 2.585.232 1.743.248 3.884-.652 5.383-1.287 2.144-1.302 2.45.392 3.66 1.04.742 5.289 3.865 11.2 7.416.015.01.022.02.037.028 1.25.753 2.988 2.595 2.988 2.595 2.662 3.08 8.45 9.277 10.97 8.11 1.19-.551-.05-3.033-.05-3.033s1.98 2.572 3.043 1.695c.809-.668-.473-3.23-.473-3.23s1.73 1.5 2.758.945c1.258-.68-.188-4.614-10.08-10.627-9.896-6.018-12.579-6.941-12.815-9.627 0 0-.004-.134.004-.365.077-.593.416-1.848 1.854-1.713 2.14.346 4.347.53 6.607.53 2.587 0 5.106-.236 7.535-.689l.002.002.164-.029c.284-.036.838-.019.84.67-.09.873-.331 1.752-.845 2.52-1.447 2.168-.971 2.466.54 3.859.934.859 5.212 4.622 11.07 8.264.013.009.016.016.03.023 1.25.752 3.41 2.814 3.41 2.814 2.428 2.466 6.895 6.595 9.328 6.346 1.646-.168.305-3.002.305-3.002s2.079 2.006 3.1 1.416c1.142-.659-.475-2.754-.475-2.754s1.338.708 2.283.473c.948-.236 1.187-2.643-8.654-8.736-9.842-6.098-13.154-8.244-12.947-10.577 0 0 .003-.379.1-.957.238-1.236.994-3.346 3.406-4.55.079-.04.147-.084.209-.13 7.668-4.45 13.27-11.614 15.246-20.56-1.99 4.941-16.737 8.78-34.647 8.78-17.903 0-32.65-3.839-34.64-8.78z', ]; diff --git a/client/js/Filter.js b/client/js/Filter.js index 911a792d0d..af55ffd6f3 100644 --- a/client/js/Filter.js +++ b/client/js/Filter.js @@ -5,5 +5,5 @@ export const FilterType = { NEWEST: 'newest', UNREAD: 'unread', - STARRED: 'starred' + STARRED: 'starred', }; diff --git a/client/js/helpers/ajax.js b/client/js/helpers/ajax.js index c944d27467..14d6717b4f 100644 --- a/client/js/helpers/ajax.js +++ b/client/js/helpers/ajax.js @@ -16,19 +16,22 @@ export const rejectUnless = (pred) => (response) => { } }; - /** * 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); + return rejectUnless((response) => response.ok)(response); }; /** * Override fetch options. */ -export const options = (newOpts) => (fetch) => (url, opts = {}) => fetch(url, mergeDeepLeft(opts, newOpts)); +export const options = + (newOpts) => + (fetch) => + (url, opts = {}) => + fetch(url, mergeDeepLeft(opts, newOpts)); /** * Override just a single fetch option. @@ -45,7 +48,6 @@ export const headers = (value) => option('headers', value); */ export const header = (name, value) => headers({ [name]: value }); - /** * Lift a wrapper function so that it can wrap a function returning more than just a Promise. * @@ -56,102 +58,115 @@ export const header = (name, value) => headers({ [name]: value }); * * @sig ((...params → Promise) → (...params → Promise)) → (...params → {promise: Promise, ...}) → (...params → {promise: Promise, ...}) */ -export const liftToPromiseField = (wrapper) => (f) => (...params) => { - let rest; - const promise = wrapper((...innerParams) => { - const {promise, ...innerRest} = f(...innerParams); - rest = innerRest; - return promise; - })(...params); - - return {promise, ...rest}; -}; - +export const liftToPromiseField = + (wrapper) => + (f) => + (...params) => { + let rest; + const promise = wrapper((...innerParams) => { + const { promise, ...innerRest } = f(...innerParams); + rest = innerRest; + return promise; + })(...params); + + return { promise, ...rest }; + }; /** * Wrapper for fetch that makes it cancellable using AbortController. * @return {controller: AbortController, promise: Promise} */ -export const makeAbortableFetch = (fetch) => (url, opts = {}) => { - const controller = opts.abortController || new AbortController(); - const promise = fetch(url, { - signal: controller.signal, - ...opts - }); - - return {controller, promise}; -}; +export const makeAbortableFetch = + (fetch) => + (url, opts = {}) => { + const controller = opts.abortController || new AbortController(); + const promise = fetch(url, { + signal: controller.signal, + ...opts, + }); + return { controller, promise }; + }; /** * Wrapper for abortable fetch that adds timeout support. * @return {controller: AbortController, promise: Promise} */ -export const makeFetchWithTimeout = (abortableFetch) => (url, opts = {}) => { - // offline db consistency requires ajax calls to fail reliably, - // so we enforce a default timeout on ajax calls - const { timeout = 60000, ...rest } = opts; - const {controller, promise} = abortableFetch(url, rest); - - if (timeout !== 0) { - const newPromise = promise.catch((error) => { - // Change error name in case of time out so that we can - // distinguish it from explicit abort. - if (error.name === 'AbortError' && promise.timedOut) { - error = new TimeoutError(`Request timed out after ${timeout / 1000} seconds`); - } - - throw error; - }); - - setTimeout(() => { - promise.timedOut = true; - controller.abort(); - }, timeout); - - return {controller, promise: newPromise}; - } - - return {controller, promise}; -}; - +export const makeFetchWithTimeout = + (abortableFetch) => + (url, opts = {}) => { + // offline db consistency requires ajax calls to fail reliably, + // so we enforce a default timeout on ajax calls + const { timeout = 60000, ...rest } = opts; + const { controller, promise } = abortableFetch(url, rest); + + if (timeout !== 0) { + const newPromise = promise.catch((error) => { + // Change error name in case of time out so that we can + // distinguish it from explicit abort. + if (error.name === 'AbortError' && promise.timedOut) { + error = new TimeoutError( + `Request timed out after ${timeout / 1000} seconds`, + ); + } + + throw error; + }); + + setTimeout(() => { + promise.timedOut = true; + controller.abort(); + }, timeout); + + return { controller, promise: newPromise }; + } + + return { controller, promise }; + }; /** * Wrapper for fetch that makes it fail on HTTP errors. * @return Promise */ -export const makeFetchFailOnHttpErrors = (fetch) => (url, opts = {}) => { - const { failOnHttpErrors = true, ...rest } = opts; - const promise = fetch(url, rest); - - if (failOnHttpErrors) { - return promise.then(rejectIfNotOkay); - } +export const makeFetchFailOnHttpErrors = + (fetch) => + (url, opts = {}) => { + const { failOnHttpErrors = true, ...rest } = opts; + const promise = fetch(url, rest); - return promise; -}; + if (failOnHttpErrors) { + return promise.then(rejectIfNotOkay); + } + return promise; + }; /** * Wrapper for fetch that converts URLSearchParams body of GET requests to query string. */ -export const makeFetchSupportGetBody = (fetch) => (url, opts = {}) => { - const { body, method, ...rest } = opts; - - let newUrl = url; - let newOpts = opts; - if (Object.keys(opts).includes('method') && Object.keys(opts).includes('body') && method.toUpperCase() === 'GET' && body instanceof URLSearchParams) { - const [main, ...fragments] = newUrl.split('#'); - const separator = main.includes('?') ? '&' : '?'; - // 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 }; - } - - return fetch(newUrl, newOpts); -}; - +export const makeFetchSupportGetBody = + (fetch) => + (url, opts = {}) => { + const { body, method, ...rest } = opts; + + let newUrl = url; + let newOpts = opts; + if ( + Object.keys(opts).includes('method') && + Object.keys(opts).includes('body') && + method.toUpperCase() === 'GET' && + body instanceof URLSearchParams + ) { + const [main, ...fragments] = newUrl.split('#'); + const separator = main.includes('?') ? '&' : '?'; + // 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 }; + } + + return fetch(newUrl, newOpts); + }; /** * Cancellable fetch with timeout support that rejects on HTTP errors. @@ -166,23 +181,22 @@ export const fetch = pipe( makeFetchFailOnHttpErrors, makeFetchSupportGetBody, makeAbortableFetch, - makeFetchWithTimeout + makeFetchWithTimeout, )(window.fetch); - export const get = liftToPromiseField(option('method', 'GET'))(fetch); - export const post = liftToPromiseField(option('method', 'POST'))(fetch); - export const delete_ = liftToPromiseField(option('method', 'DELETE'))(fetch); - /** * Using URLSearchParams directly handles dictionaries inconveniently. * For example, it joins arrays with commas or includes undefined keys. */ -export const makeSearchParams = (data) => new URLSearchParams(formurlencoded(data, { - ignorenull: true -})); +export const makeSearchParams = (data) => + new URLSearchParams( + formurlencoded(data, { + ignorenull: true, + }), + ); diff --git a/client/js/helpers/authorizations.js b/client/js/helpers/authorizations.js index 2b4b91cb34..d34555a3bf 100644 --- a/client/js/helpers/authorizations.js +++ b/client/js/helpers/authorizations.js @@ -1,7 +1,6 @@ import { useListenableValue } from './hooks'; import { useMemo } from 'react'; - export function useLoggedIn() { return useListenableValue(selfoss.loggedin); } @@ -9,26 +8,17 @@ export function useLoggedIn() { export function useAllowedToRead() { const loggedIn = useLoggedIn(); - return useMemo( - () => selfoss.isAllowedToRead(), - [loggedIn], - ); + return useMemo(() => selfoss.isAllowedToRead(), [loggedIn]); } export function useAllowedToUpdate() { const loggedIn = useLoggedIn(); - return useMemo( - () => selfoss.isAllowedToUpdate(), - [loggedIn], - ); + return useMemo(() => selfoss.isAllowedToUpdate(), [loggedIn]); } export function useAllowedToWrite() { const loggedIn = useLoggedIn(); - return useMemo( - () => selfoss.isAllowedToWrite(), - [loggedIn], - ); + return useMemo(() => selfoss.isAllowedToWrite(), [loggedIn]); } diff --git a/client/js/helpers/color.js b/client/js/helpers/color.js index a52db53f8c..bcf85d85d4 100644 --- a/client/js/helpers/color.js +++ b/client/js/helpers/color.js @@ -9,12 +9,16 @@ * * @see https://24ways.org/2010/calculating-color-contrast/ */ -export function colorByBrightness(hexColor, darkColor = '#555', brightColor = '#EEE') { +export function colorByBrightness( + hexColor, + darkColor = '#555', + brightColor = '#EEE', +) { // Strip hash sign. const color = hexColor.substr(1); const r = parseInt(color.substr(0, 2), 16); const g = parseInt(color.substr(2, 2), 16); const b = parseInt(color.substr(4, 2), 16); - const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + const yiq = (r * 299 + g * 587 + b * 114) / 1000; return yiq >= 128 ? darkColor : brightColor; } diff --git a/client/js/helpers/i18n.js b/client/js/helpers/i18n.js index 2587c416ac..70901e1456 100644 --- a/client/js/helpers/i18n.js +++ b/client/js/helpers/i18n.js @@ -21,77 +21,82 @@ export function i18nFormat(translated, params) { for (let i = 0, len = translated.length; i < len; i++) { curChar = translated.charAt(i); switch (curChar) { - case '{': - if (placeholder) { - if (state == 'plural') { - pluralKeyword = buffer.trim(); - if (['zero', 'one', 'other'].includes(pluralKeyword)) { - buffer = ''; - } else { - pluralKeyword = undefined; + case '{': + if (placeholder) { + if (state == 'plural') { + pluralKeyword = buffer.trim(); + if (['zero', 'one', 'other'].includes(pluralKeyword)) { + buffer = ''; + } else { + pluralKeyword = undefined; + } } - } - } else { - formatted = formatted + buffer; - buffer = ''; - placeholder = {}; - state = 'index'; - } - break; - case '}': - case ',': - if (placeholder) { - if (state == 'index') { - const index = buffer.trim(); - const intIndex = parseInt(index); - placeholder.index = Number.isNaN(intIndex) ? index : intIndex; - placeholder.value = params[placeholder.index]; - buffer = ''; - } else if (state == 'type') { - placeholder.type = buffer.trim(); + } else { + formatted = formatted + buffer; buffer = ''; - if (placeholder.type == 'plural') { - plural = {}; - state = 'plural'; - } + placeholder = {}; + state = 'index'; } - if (curChar == '}') { - if (state == 'plural' && pluralKeyword) { - plural[pluralKeyword] = buffer; + break; + case '}': + case ',': + if (placeholder) { + if (state == 'index') { + const index = buffer.trim(); + const intIndex = parseInt(index); + placeholder.index = Number.isNaN(intIndex) + ? index + : intIndex; + placeholder.value = params[placeholder.index]; + buffer = ''; + } else if (state == 'type') { + placeholder.type = buffer.trim(); buffer = ''; - pluralKeyword = undefined; - } else if (plural) { - if ('zero' in plural - && placeholder.value === 0) { - pluralValue = plural.zero; - } else if ('one' in plural - && placeholder.value == 1) { - pluralValue = plural.one; + if (placeholder.type == 'plural') { + plural = {}; + state = 'plural'; + } + } + if (curChar == '}') { + if (state == 'plural' && pluralKeyword) { + plural[pluralKeyword] = buffer; + buffer = ''; + pluralKeyword = undefined; + } else if (plural) { + if ('zero' in plural && placeholder.value === 0) { + pluralValue = plural.zero; + } else if ( + 'one' in plural && + placeholder.value == 1 + ) { + pluralValue = plural.one; + } else { + pluralValue = plural.other; + } + formatted = + formatted + + pluralValue.replace('#', placeholder.value); + plural = undefined; + placeholder = undefined; + state = 'out'; } else { - pluralValue = plural.other; + formatted = formatted + placeholder.value; + placeholder = undefined; + state = 'out'; } - formatted = formatted + pluralValue.replace('#', placeholder.value); - plural = undefined; - placeholder = undefined; - state = 'out'; - } else { - formatted = formatted + placeholder.value; - placeholder = undefined; - state = 'out'; + } else if (curChar == ',' && state == 'index') { + state = 'type'; } - } else if (curChar == ',' && state == 'index') { - state = 'type'; } - } - break; - default: - buffer = buffer + curChar; - break; + break; + default: + buffer = buffer + curChar; + break; } } if (state != 'out') { - return 'Error formatting \'' + translated + '\', bug report?'; + return "Error formatting '" + translated + "', bug report?"; } formatted = formatted + buffer; diff --git a/client/js/helpers/navigation.js b/client/js/helpers/navigation.js index 31dba78e0b..93aadfc489 100644 --- a/client/js/helpers/navigation.js +++ b/client/js/helpers/navigation.js @@ -1,9 +1,8 @@ export const Direction = { PREV: 'prev', - NEXT: 'next' + NEXT: 'next', }; - /** * autoscroll */ @@ -30,4 +29,3 @@ export function autoScroll(target) { window.scrollTo({ top: targetTop }); } } - diff --git a/client/js/helpers/uri.js b/client/js/helpers/uri.js index a419331d5d..c29f57c4c5 100644 --- a/client/js/helpers/uri.js +++ b/client/js/helpers/uri.js @@ -37,9 +37,13 @@ export function filterTypeToString(type) { } } -export const ENTRIES_ROUTE_PATTERN = '/:filter(newest|unread|starred)/:category(all|tag-[^/]+|source-[0-9]+)/:id?'; +export const ENTRIES_ROUTE_PATTERN = + '/:filter(newest|unread|starred)/:category(all|tag-[^/]+|source-[0-9]+)/:id?'; -export function makeEntriesLinkLocation(location, { filter, category, id, search }) { +export function makeEntriesLinkLocation( + location, + { filter, category, id, search }, +) { const queryString = new URLSearchParams(location.search); let path; @@ -49,14 +53,14 @@ export function makeEntriesLinkLocation(location, { filter, category, id, search path = generatePath(ENTRIES_ROUTE_PATTERN, { filter: filter ?? segments[0], category: category ?? segments[1], - id: typeof id !== 'undefined' ? id : segments[2] + id: typeof id !== 'undefined' ? id : segments[2], }); } else { path = generatePath(ENTRIES_ROUTE_PATTERN, { // TODO: change default from config filter: filter ?? 'unread', category: category ?? 'all', - id + id, }); } diff --git a/client/js/icons.js b/client/js/icons.js index 103304259a..7e2fb11f71 100644 --- a/client/js/icons.js +++ b/client/js/icons.js @@ -36,7 +36,7 @@ import wallabagIcon from '../images/wallabag'; export const wallabag = { prefix: 'fac', iconName: 'wallabag', - icon: wallabagIcon + icon: wallabagIcon, }; /** @@ -73,5 +73,5 @@ export { faStar as unstar, faSyncAlt as reload, faTimes as remove, - faWifi as connection + faWifi as connection, }; diff --git a/client/js/locales.js b/client/js/locales.js index 6486ae4fd3..eb42a35632 100644 --- a/client/js/locales.js +++ b/client/js/locales.js @@ -32,34 +32,34 @@ import zhCN from '../locale/zh-CN.json'; import zhTW from '../locale/zh-TW.json'; export default { - 'ca': ca, - 'cs': cs, - 'de': de, - 'en': en, + ca: ca, + cs: cs, + de: de, + en: en, 'en-GB': enGB, - 'es': es, - 'et': et, - 'fi': fi, - 'fr': fr, + es: es, + et: et, + fi: fi, + fr: fr, 'fr-CA': frCA, - 'gl': gl, - 'he': he, - 'hu': hu, - 'id': id, - 'it': it, - 'ja': ja, - 'lv': lv, - 'nb': nb, - 'nl': nl, - 'pl': pl, - 'pt': pt, + gl: gl, + he: he, + hu: hu, + id: id, + it: it, + ja: ja, + lv: lv, + nb: nb, + nl: nl, + pl: pl, + pt: pt, 'pt-BR': ptBR, - 'rm': rm, - 'ru': ru, - 'sk': sk, - 'sv': sv, - 'tr': tr, - 'uk': uk, + rm: rm, + ru: ru, + sk: sk, + sv: sv, + tr: tr, + uk: uk, 'zh-CN': zhCN, - 'zh-TW': zhTW + 'zh-TW': zhTW, }; diff --git a/client/js/requests/LoadingState.js b/client/js/requests/LoadingState.js index b1647fdbba..e08d1ce23f 100644 --- a/client/js/requests/LoadingState.js +++ b/client/js/requests/LoadingState.js @@ -6,5 +6,5 @@ export const LoadingState = { INITIAL: 'initial', LOADING: 'loading', SUCCESS: 'success', - FAILURE: 'failure' + FAILURE: 'failure', }; diff --git a/client/js/requests/common.js b/client/js/requests/common.js index 68d7c00cf0..a89be52ac1 100644 --- a/client/js/requests/common.js +++ b/client/js/requests/common.js @@ -12,44 +12,48 @@ export class PasswordHashingError extends Error { * Gets information about selfoss instance. */ export function getInstanceInfo() { - return ajax.get('api/about', { - // we want fresh configuration each time - cache: 'no-store' - }).promise.then(response => response.json()); + return ajax + .get('api/about', { + // we want fresh configuration each time + cache: 'no-store', + }) + .promise.then((response) => response.json()); } /** * Signs in user with provided credentials. */ export function login(credentials) { - return ajax.post('login', { - body: new URLSearchParams(credentials) - }).promise.then( - response => response.json() - ).then((data) => { - if (data.success) { - return Promise.resolve(); - } else { - return Promise.reject(new LoginError(data.error)); - } - }); + return ajax + .post('login', { + body: new URLSearchParams(credentials), + }) + .promise.then((response) => response.json()) + .then((data) => { + if (data.success) { + return Promise.resolve(); + } else { + return Promise.reject(new LoginError(data.error)); + } + }); } /** * Salt and hash a password. */ export function hashPassword(password) { - return ajax.post('api/private/hash-password', { - body: new URLSearchParams({ password }), - }).promise.then( - response => response.json() - ).then((data) => { - if (data.success) { - return Promise.resolve(data.hash); - } else { - return Promise.reject(new PasswordHashingError(data.error)); - } - }); + return ajax + .post('api/private/hash-password', { + body: new URLSearchParams({ password }), + }) + .promise.then((response) => response.json()) + .then((data) => { + if (data.success) { + return Promise.resolve(data.hash); + } else { + return Promise.reject(new PasswordHashingError(data.error)); + } + }); } /** @@ -59,12 +63,22 @@ export function importOpml(file) { const data = new FormData(); data.append('opml', file); - return ajax.post('opml', { - body: data, - failOnHttpErrors: false, - }).promise - .then(ajax.rejectUnless(response => response.status === 200 || response.status === 202 || response.status === 400)) - .then(response => response.json().then(data => ({ response, data }))); + return ajax + .post('opml', { + body: data, + failOnHttpErrors: false, + }) + .promise.then( + ajax.rejectUnless( + (response) => + response.status === 200 || + response.status === 202 || + response.status === 400, + ), + ) + .then((response) => + response.json().then((data) => ({ response, data })), + ); } /** diff --git a/client/js/requests/items.js b/client/js/requests/items.js index 74bc1887a3..193c1ac6ad 100644 --- a/client/js/requests/items.js +++ b/client/js/requests/items.js @@ -15,12 +15,14 @@ function safeDate(datetimeString) { * Mark items with given ids as read. */ export function markAll(ids) { - return ajax.post('mark', { - headers: { - 'content-type': 'application/json; charset=utf-8' - }, - body: JSON.stringify(ids) - }).promise.then(response => response.json()); + return ajax + .post('mark', { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(ids), + }) + .promise.then((response) => response.json()); } /** @@ -45,7 +47,9 @@ function enrichEntry(entry) { ...entry, link: unescape(entry.link), datetime: safeDate(entry.datetime), - updatetime: entry.updatetime ? safeDate(entry.updatetime) : entry.updatetime + updatetime: entry.updatetime + ? safeDate(entry.updatetime) + : entry.updatetime, }; } @@ -55,11 +59,13 @@ function enrichEntry(entry) { function enrichItemsResponse(data) { return { ...data, - lastUpdate: data.lastUpdate ? safeDate(data.lastUpdate) : data.lastUpdate, + lastUpdate: data.lastUpdate + ? safeDate(data.lastUpdate) + : data.lastUpdate, // in getItems entries: data.entries?.map(enrichEntry), // in sync - newItems: data.newItems?.map(enrichEntry) + newItems: data.newItems?.map(enrichEntry), }; } @@ -67,13 +73,18 @@ function enrichItemsResponse(data) { * Get all items matching given filter. */ export function getItems(filter, abortController) { - return ajax.get('', { - body: ajax.makeSearchParams({ - ...filter, - fromDatetime: filter.fromDatetime ? filter.fromDatetime.toISOString() : filter.fromDatetime - }), - abortController, - }).promise.then(response => response.json()).then(enrichItemsResponse); + return ajax + .get('', { + body: ajax.makeSearchParams({ + ...filter, + fromDatetime: filter.fromDatetime + ? filter.fromDatetime.toISOString() + : filter.fromDatetime, + }), + abortController, + }) + .promise.then((response) => response.json()) + .then(enrichItemsResponse); } /** @@ -82,12 +93,14 @@ export function getItems(filter, abortController) { export function sync(updatedStatuses, syncParams) { const params = { ...syncParams, - updatedStatuses: syncParams.updatedStatuses ? syncParams.updatedStatuses.map((status) => { - return { - ...status, - datetime: status.datetime.toISOString() - }; - }) : syncParams.updatedStatuses + updatedStatuses: syncParams.updatedStatuses + ? syncParams.updatedStatuses.map((status) => { + return { + ...status, + datetime: status.datetime.toISOString(), + }; + }) + : syncParams.updatedStatuses, }; if ('since' in params) { @@ -99,11 +112,13 @@ export function sync(updatedStatuses, syncParams) { const { controller, promise } = ajax.fetch('items/sync', { method: updatedStatuses ? 'POST' : 'GET', - body: ajax.makeSearchParams(params) + body: ajax.makeSearchParams(params), }); return { controller, - promise: promise.then(response => response.json()).then(enrichItemsResponse) + promise: promise + .then((response) => response.json()) + .then(enrichItemsResponse), }; } diff --git a/client/js/requests/sources.js b/client/js/requests/sources.js index ac898025d3..928c443814 100644 --- a/client/js/requests/sources.js +++ b/client/js/requests/sources.js @@ -4,15 +4,20 @@ import * as ajax from '../helpers/ajax'; * Updates source with given ID. */ export function update(id, values) { - return ajax.post(`source/${id}`, { - headers: { - 'content-type': 'application/json; charset=utf-8' - }, - body: JSON.stringify(values), - failOnHttpErrors: false, - }).promise - .then(ajax.rejectUnless(response => response.ok || response.status === 400)) - .then(response => response.json()); + return ajax + .post(`source/${id}`, { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(values), + failOnHttpErrors: false, + }) + .promise.then( + ajax.rejectUnless( + (response) => response.ok || response.status === 400, + ), + ) + .then((response) => response.json()); } /** @@ -20,7 +25,7 @@ export function update(id, values) { */ export function refreshSingle(id) { return ajax.post('source/' + id + '/update', { - timeout: 0 + timeout: 0, }).promise; } @@ -28,12 +33,14 @@ export function refreshSingle(id) { * Triggers an update of all sources. */ export function refreshAll() { - return ajax.get('update', { - headers: { - 'Accept': 'text/event-stream', - }, - timeout: 0, - }).promise.then(response => response.text()); + return ajax + .get('update', { + headers: { + Accept: 'text/event-stream', + }, + timeout: 0, + }) + .promise.then((response) => response.text()); } /** @@ -47,30 +54,36 @@ export function remove(id) { * Gets all sources. */ export function getAllSources(abortController) { - return ajax.get('sources', { - abortController, - }).promise.then(response => response.json()); + return ajax + .get('sources', { + abortController, + }) + .promise.then((response) => response.json()); } /** * Gets list of supported spouts and their paramaters. */ export function getSpouts() { - return ajax.get('source').promise.then(response => response.json()); + return ajax.get('source').promise.then((response) => response.json()); } /** * Gets parameters for given spout. */ export function getSpoutParams(spoutClass) { - return ajax.get('source/params', { - body: ajax.makeSearchParams({ spout: spoutClass }) - }).promise.then(res => res.json()); + return ajax + .get('source/params', { + body: ajax.makeSearchParams({ spout: spoutClass }), + }) + .promise.then((res) => res.json()); } /** * Gets source unread stats. */ export function getStats() { - return ajax.get('sources/stats').promise.then(response => response.json()); + return ajax + .get('sources/stats') + .promise.then((response) => response.json()); } diff --git a/client/js/requests/tags.js b/client/js/requests/tags.js index 5b501ac3d3..7d38d139ee 100644 --- a/client/js/requests/tags.js +++ b/client/js/requests/tags.js @@ -4,7 +4,7 @@ import * as ajax from '../helpers/ajax'; * Get tags for all items. */ export function getAllTags() { - return ajax.get('tags').promise.then(response => response.json()); + return ajax.get('tags').promise.then((response) => response.json()); } /** @@ -14,7 +14,7 @@ export function updateTag(tag, color) { return ajax.post('tags/color', { body: ajax.makeSearchParams({ tag, - color - }) + color, + }), }).promise; } diff --git a/client/js/selfoss-base.js b/client/js/selfoss-base.js index 32dfa431c5..baa2f38387 100644 --- a/client/js/selfoss-base.js +++ b/client/js/selfoss-base.js @@ -38,13 +38,18 @@ const selfoss = { */ async init() { // Load off-line mode enabledness. - selfoss.db.enableOffline.update(window.localStorage.getItem('enableOffline') === 'true'); + selfoss.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 ? localStorage.getItem('configuration') : null; + const storedConfig = selfoss.db.enableOffline.value + ? localStorage.getItem('configuration') + : null; let oldConfiguration = null; try { - oldConfiguration = storedConfig !== null ? JSON.parse(storedConfig) : null; + oldConfiguration = + storedConfig !== null ? JSON.parse(storedConfig) : null; } catch { // We will try to obtain a new configuration anyway. } @@ -57,10 +62,16 @@ const selfoss = { // We are on-line, prune the user files when changed. if ('caches' in window && 'serviceWorker' in navigator) { - if (oldConfiguration === null || oldConfiguration.userCss !== configuration.userCss) { + if ( + oldConfiguration === null || + oldConfiguration.userCss !== configuration.userCss + ) { await caches.delete('userCss'); } - if (oldConfiguration === null || oldConfiguration.userJs !== configuration.userJs) { + if ( + oldConfiguration === null || + oldConfiguration.userJs !== configuration.userJs + ) { await caches.delete('userJs'); } } @@ -74,7 +85,6 @@ const selfoss = { } }, - async initMain(configuration) { selfoss.config = configuration; @@ -83,9 +93,14 @@ const selfoss = { } if (configuration.language !== null) { - document.documentElement.setAttribute('lang', configuration.language); + document.documentElement.setAttribute( + 'lang', + configuration.language, + ); } - document.querySelector('meta[name="application-name"]').setAttribute('content', configuration.htmlTitle); + document + .querySelector('meta[name="application-name"]') + .setAttribute('content', configuration.htmlTitle); const feedLink = document.createElement('link'); feedLink.setAttribute('rel', 'alternate'); @@ -110,13 +125,14 @@ const selfoss = { selfoss.dbOffline.init(); if (configuration.authEnabled) { - selfoss.loggedin.update(window.localStorage.getItem('onlineSession') == 'true'); + selfoss.loggedin.update( + window.localStorage.getItem('onlineSession') == 'true', + ); } selfoss.attachApp(configuration); }, - /** * Create basic DOM structure of the page. */ @@ -128,7 +144,7 @@ const selfoss = { mainUi.classList.add('app-toplevel'); // BrowserRouter expects no slash at the end. - const basePath = (new URL(document.baseURI)).pathname.replace(/\/$/, ''); + const basePath = new URL(document.baseURI).pathname.replace(/\/$/, ''); const root = createRoot(mainUi); root.render( @@ -138,25 +154,22 @@ const selfoss = { selfoss.app = app; }, configuration, - }) + }), ); }, loggedin: new ValueListenable(false), - setSession() { window.localStorage.setItem('onlineSession', true); selfoss.loggedin.update(true); }, - clearSession() { window.localStorage.removeItem('onlineSession'); selfoss.loggedin.update(false); }, - hasSession() { return selfoss.loggedin.value; }, @@ -167,30 +180,47 @@ const selfoss = { */ login({ configuration, username, password, enableOffline }) { selfoss.db.enableOffline.update(enableOffline); - window.localStorage.setItem('enableOffline', selfoss.db.enableOffline.value); + window.localStorage.setItem( + 'enableOffline', + selfoss.db.enableOffline.value, + ); if (!selfoss.db.enableOffline.value) { selfoss.db.clear(); } const credentials = { username, - password + password, }; return login(credentials).then(() => { selfoss.setSession(); // init offline if supported and not inited yet selfoss.dbOffline.init(); - if ((!selfoss.db.storage || selfoss.db.broken) && selfoss.db.enableOffline.value) { + if ( + (!selfoss.db.storage || selfoss.db.broken) && + selfoss.db.enableOffline.value + ) { // Initialize database in offline mode when it has not been initialized yet or it got broken. selfoss.dbOffline.init(); // Store config for off-line use. - localStorage.setItem('configuration', JSON.stringify(configuration)); + localStorage.setItem( + 'configuration', + JSON.stringify(configuration), + ); // Cache user files manually since service worker is not aware of them. if ('caches' in window && 'serviceWorker' in navigator) { - caches.open('userCss').then((cache) => cache.add(`user.css?v=${configuration.userCss}`)); - caches.open('userJs').then((cache) => cache.add(`user.js?v=${configuration.userJs}`)); + caches + .open('userCss') + .then((cache) => + cache.add(`user.css?v=${configuration.userCss}`), + ); + caches + .open('userJs') + .then((cache) => + cache.add(`user.js?v=${configuration.userJs}`), + ); } selfoss.setupServiceWorker(); @@ -199,20 +229,23 @@ const selfoss = { }, setupServiceWorker() { - if (!('serviceWorker' in navigator) || selfoss.serviceWorkerInitialized) { + if ( + !('serviceWorker' in navigator) || + selfoss.serviceWorkerInitialized + ) { return; } selfoss.serviceWorkerInitialized = true; - navigator.serviceWorker.addEventListener( - 'controllerchange', - () => { - window.location.reload(); - }, - ); + navigator.serviceWorker.addEventListener('controllerchange', () => { + window.location.reload(); + }); - navigator.serviceWorker.register(new URL('../selfoss-sw-offline.js', import.meta.url), { type: 'module' }) + navigator.serviceWorker + .register(new URL('../selfoss-sw-offline.js', import.meta.url), { + type: 'module', + }) .then((reg) => { selfoss.listenWaitingSW(reg, (reg) => { selfoss.app.notifyNewVersion(() => { @@ -231,7 +264,9 @@ const selfoss = { window.localStorage.clear(); if ('serviceWorker' in navigator) { if ('caches' in window) { - caches.keys().then(keys => keys.forEach(key => caches.delete(key))); + caches + .keys() + .then((keys) => keys.forEach((key) => caches.delete(key))); } navigator.serviceWorker.getRegistrations().then((registrations) => { @@ -249,7 +284,9 @@ const selfoss = { selfoss.history.push('/sign/in'); } } catch (error) { - selfoss.app.showError(selfoss.app._('error_logout') + ' ' + error.message); + selfoss.app.showError( + selfoss.app._('error_logout') + ' ' + error.message, + ); } }, @@ -259,7 +296,11 @@ const selfoss = { * @returns {boolean} */ isAllowedToRead() { - return selfoss.hasSession() || !selfoss.config.authEnabled || selfoss.config.publicMode; + return ( + selfoss.hasSession() || + !selfoss.config.authEnabled || + selfoss.config.publicMode + ); }, /** @@ -268,7 +309,11 @@ const selfoss = { * @returns {boolean} */ isAllowedToUpdate() { - return selfoss.hasSession() || !selfoss.config.authEnabled || selfoss.config.allowPublicUpdate; + return ( + selfoss.hasSession() || + !selfoss.config.authEnabled || + selfoss.config.allowPublicUpdate + ); }, /** @@ -289,7 +334,6 @@ const selfoss = { return selfoss.db.online; }, - /** * indicates whether a mobile device is host * @@ -297,7 +341,7 @@ const selfoss = { */ isMobile() { // first check useragent - if ((/iPhone|iPod|iPad|Android|BlackBerry/).test(navigator.userAgent)) { + if (/iPhone|iPod|iPad|Android|BlackBerry/.test(navigator.userAgent)) { return true; } @@ -305,7 +349,6 @@ const selfoss = { return selfoss.isTablet() || selfoss.isSmartphone(); }, - /** * indicates whether a tablet is the device or not * @@ -318,7 +361,6 @@ const selfoss = { return false; }, - /** * indicates whether a tablet is the device or not * @@ -331,7 +373,6 @@ const selfoss = { return false; }, - /** * Override these functions to customize selfoss behaviour. */ @@ -358,7 +399,6 @@ const selfoss = { selfoss.refreshUnread(unread); }, - /** * refresh unread stats. * @@ -369,7 +409,6 @@ const selfoss = { selfoss.app.setUnreadItemsCount(unread); }, - /** * refresh current tags. * @@ -378,16 +417,19 @@ const selfoss = { reloadTags() { selfoss.app.setTagsState(LoadingState.LOADING); - getAllTags().then((data) => { - selfoss.app.setTags(data); - selfoss.app.setTagsState(LoadingState.SUCCESS); - }).catch((error) => { - selfoss.app.setTagsState(LoadingState.FAILURE); - selfoss.app.showError(selfoss.app._('error_load_tags') + ' ' + error.message); - }); + getAllTags() + .then((data) => { + selfoss.app.setTags(data); + selfoss.app.setTagsState(LoadingState.SUCCESS); + }) + .catch((error) => { + selfoss.app.setTagsState(LoadingState.FAILURE); + selfoss.app.showError( + selfoss.app._('error_load_tags') + ' ' + error.message, + ); + }); }, - handleAjaxError(error, tryOffline = true) { if (!(error instanceof HttpError || error instanceof TimeoutError)) { return Promise.reject(error); @@ -402,7 +444,6 @@ const selfoss = { } }, - listenWaitingSW(reg, callback) { const awaitStateChange = () => { reg.installing.addEventListener('statechange', (event) => { @@ -423,8 +464,7 @@ const selfoss = { }, // Include helpers for user scripts. - ajax - + ajax, }; export default selfoss; diff --git a/client/js/selfoss-db-offline.js b/client/js/selfoss-db-offline.js index 9b89efd8e6..7565f48fc8 100644 --- a/client/js/selfoss-db-offline.js +++ b/client/js/selfoss-db-offline.js @@ -3,10 +3,7 @@ import { OfflineStorageNotAvailableError } from './errors'; import Dexie from 'dexie'; import { FilterType } from './Filter'; - selfoss.dbOffline = { - - /** @var Date the datetime of the newest garbage collected entry, i.e. deleted because not of interest. */ newestGCedEntry: null, offlineDays: 10, @@ -17,26 +14,24 @@ selfoss.dbOffline = { olderEntriesOnline: false, _tr(...args) { - return selfoss.db.storage.transaction(...args) - .catch((error) => { - selfoss.app.showError( - selfoss.app._('error_offline_storage', [error.message]) - ); - selfoss.db.broken = true; - selfoss.db.enableOffline.update(false); - selfoss.entries?.reload(); - - // If this is a QuotaExceededError, garbage collect more - // entries and hope it helps. - if (error.name === Dexie.errnames.QuotaExceeded) { - selfoss.dbOffline.GCEntries(true); - } + return selfoss.db.storage.transaction(...args).catch((error) => { + selfoss.app.showError( + selfoss.app._('error_offline_storage', [error.message]), + ); + selfoss.db.broken = true; + selfoss.db.enableOffline.update(false); + selfoss.entries?.reload(); + + // If this is a QuotaExceededError, garbage collect more + // entries and hope it helps. + if (error.name === Dexie.errnames.QuotaExceeded) { + selfoss.dbOffline.GCEntries(true); + } - return Promise.reject(error); - }); + return Promise.reject(error); + }); }, - init() { if (!selfoss.db.enableOffline.value || selfoss.db.storage) { return; @@ -50,42 +45,57 @@ selfoss.dbOffline = { stamps: '&name,datetime', stats: '&name', tags: '&name', - sources: '&id' + sources: '&id', }); selfoss.db.storage.on('populate', () => { - selfoss.db.storage.stats.add({name: 'unread', value: 0}); - selfoss.db.storage.stats.add({name: 'starred', value: 0}); - selfoss.db.storage.stats.add({name: 'total', value: 0}); + selfoss.db.storage.stats.add({ name: 'unread', value: 0 }); + selfoss.db.storage.stats.add({ name: 'starred', value: 0 }); + selfoss.db.storage.stats.add({ name: 'total', value: 0 }); }); // retrieve last update stats in offline db - return selfoss.dbOffline._tr( - 'r', - selfoss.db.storage.entries, - selfoss.db.storage.stamps, - () => { - selfoss.dbOffline._memLastItemId(); - selfoss.db.storage.stamps.get('lastItemsUpdate', (stamp) => { - if (stamp) { - selfoss.db.lastUpdate = stamp.datetime; - selfoss.dbOnline.firstSync = false; - } else { - selfoss.dbOffline.shouldLoadEntriesOnline = true; - } - }); - selfoss.db.storage.stamps.get('newestGCedEntry', (stamp) => { - if (stamp) { - selfoss.dbOffline.newestGCedEntry = stamp.datetime; - } + return selfoss.dbOffline + ._tr( + 'r', + selfoss.db.storage.entries, + selfoss.db.storage.stamps, + () => { + selfoss.dbOffline._memLastItemId(); + selfoss.db.storage.stamps.get( + 'lastItemsUpdate', + (stamp) => { + if (stamp) { + selfoss.db.lastUpdate = stamp.datetime; + selfoss.dbOnline.firstSync = false; + } else { + selfoss.dbOffline.shouldLoadEntriesOnline = true; + } + }, + ); + selfoss.db.storage.stamps.get( + 'newestGCedEntry', + (stamp) => { + if (stamp) { + selfoss.dbOffline.newestGCedEntry = + stamp.datetime; + } - const limit = new Date(Date.now() - 3 * 24 * 3600 * 1000); - if (!stamp || selfoss.dbOffline.newestGCedEntry < limit) { - selfoss.dbOffline.newestGCedEntry = new Date(Date.now() - 24 * 3600 * 1000); - } - }); - } - ) + const limit = new Date( + Date.now() - 3 * 24 * 3600 * 1000, + ); + if ( + !stamp || + selfoss.dbOffline.newestGCedEntry < limit + ) { + selfoss.dbOffline.newestGCedEntry = new Date( + Date.now() - 24 * 3600 * 1000, + ); + } + }, + ); + }, + ) .then(() => { const offlineDays = window.localStorage.getItem('offlineDays'); if (offlineDays !== null) { @@ -94,10 +104,12 @@ selfoss.dbOffline = { // 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(Math.max( - selfoss.dbOffline.newestGCedEntry, - Date.now() - (selfoss.dbOffline.offlineDays * 86400000) - )); + selfoss.dbOffline.newestGCedEntry = new Date( + Math.max( + selfoss.dbOffline.newestGCedEntry, + Date.now() - selfoss.dbOffline.offlineDays * 86400000, + ), + ); window.addEventListener('online', () => { selfoss.db.tryOnline(); @@ -105,10 +117,15 @@ selfoss.dbOffline = { window.addEventListener('offline', () => { selfoss.db.setOffline().catch((error) => { if (error instanceof OfflineStorageNotAvailableError) { - selfoss.app.showError(selfoss.app._('error_offline_storage_not_available', [ - '', - '' - ])); + selfoss.app.showError( + selfoss.app._( + 'error_offline_storage_not_available', + [ + '', + '', + ], + ), + ); } else { throw error; } @@ -116,30 +133,31 @@ selfoss.dbOffline = { }); selfoss.app.setOfflineState(false); - selfoss.db.tryOnline() - .then(() => { - selfoss.reloadTags(); - }); + selfoss.db.tryOnline().then(() => { + selfoss.reloadTags(); + }); selfoss.dbOffline.reloadOnlineStats(); selfoss.dbOffline.refreshStats(); - }).catch(() => { + }) + .catch(() => { selfoss.db.broken = true; selfoss.db.enableOffline.update(false); }); }, - _memLastItemId() { - return selfoss.db.storage.entries.orderBy('id').reverse().first((entry) => { - if (entry) { - selfoss.dbOffline.lastItemId = entry.id; - } else { - selfoss.dbOffline.lastItemId = 0; - } - }); + return selfoss.db.storage.entries + .orderBy('id') + .reverse() + .first((entry) => { + if (entry) { + selfoss.dbOffline.lastItemId = entry.id; + } else { + selfoss.dbOffline.lastItemId = 0; + } + }); }, - storeEntries(entries) { return selfoss.dbOffline._tr( 'rw', @@ -153,98 +171,120 @@ selfoss.dbOffline = { selfoss.dbOffline._memLastItemId(); selfoss.dbOffline.refreshStats(); }); - } + }, ); }, - GCEntries(more = false) { if (more) { // We need to garbage collect more, as the browser storage limit // seems to be exceeded: decrease the amount of days entries are // kept offline. - const keptDays = Math.floor((new Date() - - selfoss.dbOffline.newestGCedEntry) / - 86400000); + const keptDays = Math.floor( + (new Date() - selfoss.dbOffline.newestGCedEntry) / 86400000, + ); selfoss.dbOffline.offlineDays = Math.max( Math.min(keptDays - 1, selfoss.dbOffline.offlineDays - 1), - 0 + 0, + ); + window.localStorage.setItem( + 'offlineDays', + selfoss.dbOffline.offlineDays, ); - window.localStorage.setItem('offlineDays', - selfoss.dbOffline.offlineDays); } - return selfoss.db.storage.transaction('rw', + return selfoss.db.storage.transaction( + 'rw', selfoss.db.storage.entries, selfoss.db.storage.stamps, () => { // cleanup and remember when selfoss.db.storage.stamps.get('lastCleanup', (stamp) => { // Cleanup once a day or once after db reset - if (!stamp || more || (stamp && Date.now() - stamp.datetime > 24 * 3600 * 1000)) { + if ( + !stamp || + more || + (stamp && + Date.now() - stamp.datetime > 24 * 3600 * 1000) + ) { // Cleanup items older than offlineDays days, not of // interest. - const limit = new Date(Date.now() - - selfoss.dbOffline.offlineDays * 24 * 3600 * 1000); - - selfoss.db.storage.entries.where('datetime').below(limit) + const limit = new Date( + Date.now() - + selfoss.dbOffline.offlineDays * + 24 * + 3600 * + 1000, + ); + + selfoss.db.storage.entries + .where('datetime') + .below(limit) .filter((entry) => { return !entry.unread && !entry.starred; - }).each((entry) => { + }) + .each((entry) => { selfoss.db.storage.entries.delete(entry.id); - if (selfoss.dbOffline.newestGCedEntry < entry.datetime) { - selfoss.dbOffline.newestGCedEntry = entry.datetime; + if ( + selfoss.dbOffline.newestGCedEntry < + entry.datetime + ) { + selfoss.dbOffline.newestGCedEntry = + entry.datetime; } - } - ).then(() => { + }) + .then(() => { selfoss.db.storage.stamps.bulkPut([ - {name: 'lastCleanup', datetime: new Date()}, + { + name: 'lastCleanup', + datetime: new Date(), + }, { name: 'newestGCedEntry', - datetime: selfoss.dbOffline.newestGCedEntry - } + datetime: + selfoss.dbOffline.newestGCedEntry, + }, ]); }); } }); - }); + }, + ); }, - storeStats(stats) { return selfoss.dbOffline._tr('rw', selfoss.db.storage.stats, () => { for (const [name, value] of Object.entries(stats)) { selfoss.db.storage.stats.put({ name, - value + value, }); } }); }, - storeLastUpdate(lastUpdate) { return selfoss.dbOffline._tr('rw', selfoss.db.storage.stamps, () => { if (lastUpdate) { selfoss.db.storage.stamps.put({ name: 'lastItemsUpdate', - datetime: lastUpdate + datetime: lastUpdate, }); } }); }, - getEntries(fetchParams) { let hasMore = false; - return selfoss.dbOffline._tr( - 'r', - selfoss.db.storage.entries, - () => { + return selfoss.dbOffline + ._tr('r', selfoss.db.storage.entries, () => { let howMany = 0; - const ascOrder = selfoss.config.unreadOrder === 'asc' && fetchParams.type === FilterType.UNREAD; - let entries = selfoss.db.storage.entries.orderBy('[datetime+id]'); + const ascOrder = + selfoss.config.unreadOrder === 'asc' && + fetchParams.type === FilterType.UNREAD; + let entries = + selfoss.db.storage.entries.orderBy('[datetime+id]'); if (!ascOrder) { entries = entries.reverse(); } @@ -252,92 +292,98 @@ selfoss.dbOffline = { const fromDatetime = fetchParams.fromDatetime; const fromId = fetchParams.fromId; const seek = fromDatetime && fromId; - const alwaysInDb = fetchParams.type === FilterType.STARRED - || fetchParams.type === FilterType.UNREAD; + const alwaysInDb = + fetchParams.type === FilterType.STARRED || + fetchParams.type === FilterType.UNREAD; - return entries.filter((entry) => { - let keepEntry = false; - - if (fetchParams.extraIds.includes(entry.id)) { - return true; - } + return entries + .filter((entry) => { + let keepEntry = false; - if (fetchParams.type === FilterType.STARRED) { - keepEntry = entry.starred; - } else if (fetchParams.type === FilterType.UNREAD) { - keepEntry = entry.unread; - } else { - keepEntry = true; - } + if (fetchParams.extraIds.includes(entry.id)) { + return true; + } - // seek pagination - if (seek) { - if (ascOrder) { - keepEntry &&= entry.datetime > fromDatetime - || (entry.datetime.getTime() == fromDatetime.getTime() - && entry.id > fromId); + if (fetchParams.type === FilterType.STARRED) { + keepEntry = entry.starred; + } else if (fetchParams.type === FilterType.UNREAD) { + keepEntry = entry.unread; } else { - keepEntry &&= entry.datetime < fromDatetime - || (entry.datetime.getTime() == fromDatetime.getTime() - && entry.id < fromId); + keepEntry = true; } - } - return keepEntry; - }).until((entry) => { - howMany += 1; + // seek pagination + if (seek) { + if (ascOrder) { + keepEntry &&= + entry.datetime > fromDatetime || + (entry.datetime.getTime() == + fromDatetime.getTime() && + entry.id > fromId); + } else { + keepEntry &&= + entry.datetime < fromDatetime || + (entry.datetime.getTime() == + fromDatetime.getTime() && + entry.id < fromId); + } + } - if (!ascOrder && !alwaysInDb && entry.datetime < selfoss.dbOffline.newestGCedEntry) { - // the offline db is missing older entries, the next - // seek will have to find them online. - selfoss.dbOffline.olderEntriesOnline = true; - hasMore = true; - return true; // stop iteration - } + return keepEntry; + }) + .until((entry) => { + howMany += 1; + + if ( + !ascOrder && + !alwaysInDb && + entry.datetime < selfoss.dbOffline.newestGCedEntry + ) { + // the offline db is missing older entries, the next + // seek will have to find them online. + selfoss.dbOffline.olderEntriesOnline = true; + hasMore = true; + return true; // stop iteration + } - // stop iteration if enough entries have been shown - // go one further to assess if has more - if (howMany >= selfoss.config.itemsPerPage + 1) { - hasMore = true; - return true; - } + // stop iteration if enough entries have been shown + // go one further to assess if has more + if (howMany >= selfoss.config.itemsPerPage + 1) { + hasMore = true; + return true; + } - return false; - }); + return false; + }); }) .then((entriesCollection) => entriesCollection.toArray()) .then((entries) => ({ entries, hasMore })); }, - reloadOnlineStats() { - return selfoss.dbOffline._tr( - 'r', - selfoss.db.storage.stats, - () => { - selfoss.db.storage.stats.toArray((stats) => { - const newStats = {}; - stats.forEach((stat) => { - newStats[stat.name] = stat.value; - }); - selfoss.refreshStats(newStats.total, - newStats.unread, newStats.starred); + return selfoss.dbOffline._tr('r', selfoss.db.storage.stats, () => { + selfoss.db.storage.stats.toArray((stats) => { + const newStats = {}; + stats.forEach((stat) => { + newStats[stat.name] = stat.value; }); - } - ); + selfoss.refreshStats( + newStats.total, + newStats.unread, + newStats.starred, + ); + }); + }); }, - refreshStats() { - return selfoss.dbOffline._tr( - 'r', - selfoss.db.storage.entries, - () => { - const offlineCounts = {newest: 0, unread: 0, starred: 0}; + return selfoss.dbOffline._tr('r', selfoss.db.storage.entries, () => { + const offlineCounts = { newest: 0, unread: 0, starred: 0 }; - // IDBKeyRange does not support boolean indexes, so we need to - // iterate over all the entries. - selfoss.db.storage.entries.each((entry) => { + // IDBKeyRange does not support boolean indexes, so we need to + // iterate over all the entries. + selfoss.db.storage.entries + .each((entry) => { offlineCounts.newest = offlineCounts.newest + 1; if (entry.unread) { offlineCounts.unread = offlineCounts.unread + 1; @@ -345,151 +391,151 @@ selfoss.dbOffline = { if (entry.starred) { offlineCounts.starred = offlineCounts.starred + 1; } - }).then(() => { + }) + .then(() => { selfoss.app.refreshOfflineCounts(offlineCounts); }); - } - ); + }); }, - enqueueStatuses(statuses) { if (statuses) { selfoss.dbOffline.needsSync = true; } const d = new Date(); - const newQueuedStatuses = statuses.map(newStatus => ({ + const newQueuedStatuses = statuses.map((newStatus) => ({ entryId: parseInt(newStatus.entryId), name: newStatus.name, value: newStatus.value, - datetime: d + datetime: d, })); - return selfoss.dbOffline._tr( - 'rw', - selfoss.db.storage.statusq, - () => { - selfoss.db.storage.statusq.bulkAdd(newQueuedStatuses); - } - ); + return selfoss.dbOffline._tr('rw', selfoss.db.storage.statusq, () => { + selfoss.db.storage.statusq.bulkAdd(newQueuedStatuses); + }); }, - enqueueStatus(entryId, statusName, statusValue) { - return selfoss.dbOffline.enqueueStatuses([{ - entryId: entryId, - name: statusName, - value: statusValue - }]); + return selfoss.dbOffline.enqueueStatuses([ + { + entryId: entryId, + name: statusName, + value: statusValue, + }, + ]); }, - sendNewStatuses() { - selfoss.db.storage.statusq.toArray().then(statuses => { - return statuses.map(s => { - const statusUpdate = { - id: s.entryId, - datetime: s.datetime - }; - statusUpdate[s.name] = s.value; - - return statusUpdate; - }); - }).then(statuses => { - const s = statuses.length > 0 ? statuses : undefined; - selfoss.dbOnline.sync(s, true).then(() => { - selfoss.dbOffline.needsSync = false; + selfoss.db.storage.statusq + .toArray() + .then((statuses) => { + return statuses.map((s) => { + const statusUpdate = { + id: s.entryId, + datetime: s.datetime, + }; + statusUpdate[s.name] = s.value; + + return statusUpdate; + }); + }) + .then((statuses) => { + const s = statuses.length > 0 ? statuses : undefined; + selfoss.dbOnline.sync(s, true).then(() => { + selfoss.dbOffline.needsSync = false; + }); }); - }); return selfoss.dbOnline._syncBegin(); }, - storeEntryStatuses(itemStatuses, dequeue = false, updateStats = true) { - return selfoss.dbOffline._tr( - 'rw', - selfoss.db.storage.entries, - selfoss.db.storage.stats, - selfoss.db.storage.statusq, - () => { - const statsDiff = {}; - - // update entries statuses - itemStatuses.forEach((itemStatus) => { - const newStatus = {}; - - selfoss.db.entryStatusNames.forEach((statusName) => { - if (statusName in itemStatus) { - statsDiff[statusName] = 0; - newStatus[statusName] = itemStatus[statusName]; - - if (updateStats) { - if (itemStatus[statusName]) { - statsDiff[statusName]++; - } else { - statsDiff[statusName]--; + return selfoss.dbOffline + ._tr( + 'rw', + selfoss.db.storage.entries, + selfoss.db.storage.stats, + selfoss.db.storage.statusq, + () => { + const statsDiff = {}; + + // update entries statuses + itemStatuses.forEach((itemStatus) => { + const newStatus = {}; + + selfoss.db.entryStatusNames.forEach((statusName) => { + if (statusName in itemStatus) { + statsDiff[statusName] = 0; + newStatus[statusName] = itemStatus[statusName]; + + if (updateStats) { + if (itemStatus[statusName]) { + statsDiff[statusName]++; + } else { + statsDiff[statusName]--; + } } } - } - }); + }); - const id = parseInt(itemStatus.id); - selfoss.db.storage.entries.get(id).then( - () => { - selfoss.db.storage.entries.update(id, newStatus); - }, - () => { - // the key was not found, the status of an entry - // missing in db was updated, request sync. - selfoss.dbOffline.needsSync = true; + const id = parseInt(itemStatus.id); + selfoss.db.storage.entries.get(id).then( + () => { + selfoss.db.storage.entries.update( + id, + newStatus, + ); + }, + () => { + // the key was not found, the status of an entry + // missing in db was updated, request sync. + selfoss.dbOffline.needsSync = true; + }, + ); + + if (dequeue) { + // status update from server, remove from status queue + selfoss.db.storage.statusq + .where('entryId') + .equals(id) + .delete(); } - ); - - if (dequeue) { - // status update from server, remove from status queue - selfoss.db.storage.statusq - .where('entryId').equals(id) - .delete(); - } - }); + }); - if (updateStats) { - for (const [name, value] of Object.entries(statsDiff)) { - selfoss.db.storage.stats.get(name, (stat) => { - selfoss.db.storage.stats.put({ - name, - value: stat.value + value + if (updateStats) { + for (const [name, value] of Object.entries(statsDiff)) { + selfoss.db.storage.stats.get(name, (stat) => { + selfoss.db.storage.stats.put({ + name, + value: stat.value + value, + }); }); - }); + } } - } - } - ).then(selfoss.dbOffline.refreshStats); + }, + ) + .then(selfoss.dbOffline.refreshStats); }, - entriesMark(itemIds, unread) { selfoss.dbOnline.statsDirty = true; const newStatuses = itemIds.map((itemId) => { - return {id: itemId, unread: unread}; + return { id: itemId, unread: unread }; }); return selfoss.dbOffline.storeEntryStatuses(newStatuses); }, - entryMark(itemId, unread) { return selfoss.dbOffline.entriesMark([itemId], unread); }, - entryStar(itemId, starred) { - return selfoss.dbOffline.storeEntryStatuses([{ - id: itemId, - starred: starred - }]); - } - - + return selfoss.dbOffline.storeEntryStatuses([ + { + id: itemId, + starred: starred, + }, + ]); + }, }; diff --git a/client/js/selfoss-db-online.js b/client/js/selfoss-db-online.js index 1aad022eba..0b9370358f 100644 --- a/client/js/selfoss-db-online.js +++ b/client/js/selfoss-db-online.js @@ -4,43 +4,42 @@ import { LoadingState } from './requests/LoadingState'; import { FilterType } from './Filter'; selfoss.dbOnline = { - - syncing: { promise: null, request: null, resolve: null, - reject: null + reject: null, }, statsDirty: false, firstSync: 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(); + 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 { 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; @@ -51,7 +50,6 @@ selfoss.dbOnline = { return selfoss.dbOnline.syncing.promise; }, - _syncDone(success = true) { if (selfoss.dbOnline.syncing.promise) { if (success) { @@ -66,7 +64,6 @@ selfoss.dbOnline = { } }, - /** * sync server status. * @@ -95,7 +92,7 @@ selfoss.dbOnline = { since: selfoss.db.lastUpdate, tags: true, sources: selfoss.app.state.navSourcesExpanded || undefined, - itemsStatuses: getStatuses + itemsStatuses: getStatuses, }; if (updatedStatuses && updatedStatuses.length > 0) { @@ -110,163 +107,202 @@ selfoss.dbOnline = { selfoss.dbOnline.statsDirty = false; - selfoss.dbOnline.syncing.request = itemsRequests.sync(updatedStatuses, syncParams); - - selfoss.dbOnline.syncing.request.promise.then((data) => { - selfoss.db.setOnline(); + selfoss.dbOnline.syncing.request = itemsRequests.sync( + updatedStatuses, + syncParams, + ); - selfoss.db.lastSync = Date.now(); - selfoss.dbOnline.firstSync = false; + selfoss.dbOnline.syncing.request.promise + .then((data) => { + selfoss.db.setOnline(); - const dataDate = data.lastUpdate; + selfoss.db.lastSync = Date.now(); + selfoss.dbOnline.firstSync = false; - let storing = false; + const dataDate = data.lastUpdate; - if (selfoss.db.enableOffline.value) { - if ('newItems' in data) { - let maxId = 0; - data.newItems.forEach((item) => { - maxId = Math.max(item.id, maxId); - }); + let storing = false; - selfoss.dbOffline.newerEntriesMissing = 'lastId' in data - && data.lastId > selfoss.dbOffline.lastItemId - && data.lastId > maxId; - storing = selfoss.dbOffline.newerEntriesMissing; + if (selfoss.db.enableOffline.value) { + if ('newItems' in data) { + let maxId = 0; + data.newItems.forEach((item) => { + maxId = Math.max(item.id, maxId); + }); - selfoss.dbOffline - .shouldLoadEntriesOnline = 'lastId' in data - && data.lastId - selfoss.dbOffline.lastItemId > - 2 * selfoss.config.itemsPerPage; + selfoss.dbOffline.newerEntriesMissing = + 'lastId' in data && + data.lastId > selfoss.dbOffline.lastItemId && + data.lastId > maxId; + storing = selfoss.dbOffline.newerEntriesMissing; + + selfoss.dbOffline.shouldLoadEntriesOnline = + 'lastId' in data && + data.lastId - selfoss.dbOffline.lastItemId > + 2 * selfoss.config.itemsPerPage; + + selfoss.dbOffline + .storeEntries(data.newItems) + .then(() => { + selfoss.dbOffline.storeLastUpdate(dataDate); + selfoss.dbOnline._syncDone(); + }); + } - selfoss.dbOffline.storeEntries(data.newItems) - .then(() => { - selfoss.dbOffline.storeLastUpdate(dataDate); - selfoss.dbOnline._syncDone(); + if ( + selfoss.dbOffline.newerEntriesMissing || + selfoss.dbOffline.needsSync + ) { + // There are still new items to fetch + // or statuses to send + syncing.then(() => { + selfoss.dbOffline.sendNewStatuses(); }); - } + } - if (selfoss.dbOffline.newerEntriesMissing - || selfoss.dbOffline.needsSync) { - // There are still new items to fetch - // or statuses to send - syncing.then(() => { - selfoss.dbOffline.sendNewStatuses(); - }); - } + if ('itemUpdates' in data) { + // refresh entry statuses in db and dequeue queued + // statuses but do not calculate stats as they are taken + // directly from the server as provided. + selfoss.dbOffline + .storeEntryStatuses(data.itemUpdates, true, false) + .then(() => { + selfoss.dbOffline.storeLastUpdate(dataDate); + }); + } - if ('itemUpdates' in data) { - // refresh entry statuses in db and dequeue queued - // statuses but do not calculate stats as they are taken - // directly from the server as provided. - selfoss.dbOffline - .storeEntryStatuses(data.itemUpdates, true, false) - .then(() => { - selfoss.dbOffline.storeLastUpdate(dataDate); - }); + if ('stats' in data) { + selfoss.dbOffline.storeStats(data.stats); + } } - if ('stats' in data) { - selfoss.dbOffline.storeStats(data.stats); + if (!selfoss.dbOnline.statsDirty && 'stats' in data) { + selfoss.refreshStats( + data.stats.total, + data.stats.unread, + data.stats.starred, + ); } - } - - if (!selfoss.dbOnline.statsDirty && 'stats' in data) { - selfoss.refreshStats(data.stats.total, - data.stats.unread, - data.stats.starred); - } - - if ('tags' in data) { - selfoss.app.setTags(data.tags); - selfoss.app.setTagsState(LoadingState.SUCCESS); - } - if ('sources' in data) { - selfoss.app.setSources(data.sources); - selfoss.app.setSourcesState(LoadingState.SUCCESS); - } + if ('tags' in data) { + selfoss.app.setTags(data.tags); + selfoss.app.setTagsState(LoadingState.SUCCESS); + } - if ('stats' in data && data.stats.unread > 0 && - selfoss.entriesPage && (selfoss.entriesPage.state.entries.length === 0 || - selfoss.entriesPage.state.entries.loadingState === LoadingState.FAILURE)) { - selfoss.entriesPage?.reload(); - } else { - if ('itemUpdates' in data) { - selfoss.entriesPage.refreshEntryStatuses(data.itemUpdates); + if ('sources' in data) { + selfoss.app.setSources(data.sources); + selfoss.app.setSourcesState(LoadingState.SUCCESS); } - if (selfoss.entriesPage && selfoss.entriesPage.getActiveFilter() === FilterType.UNREAD) { - const unreadCount = 'stats' in data ? data.stats.unread : selfoss.app.state.unreadItemsCount; + if ( + 'stats' in data && + data.stats.unread > 0 && + selfoss.entriesPage && + (selfoss.entriesPage.state.entries.length === 0 || + selfoss.entriesPage.state.entries.loadingState === + LoadingState.FAILURE) + ) { + selfoss.entriesPage?.reload(); + } else { + if ('itemUpdates' in data) { + selfoss.entriesPage.refreshEntryStatuses( + data.itemUpdates, + ); + } - if (unreadCount > selfoss.entriesPage.state.entries.filter(({ unread }) => unread == 1).length) { - selfoss.entriesPage.setHasMore(true); + if ( + selfoss.entriesPage && + selfoss.entriesPage.getActiveFilter() === + FilterType.UNREAD + ) { + const unreadCount = + 'stats' in data + ? data.stats.unread + : selfoss.app.state.unreadItemsCount; + + if ( + unreadCount > + selfoss.entriesPage.state.entries.filter( + ({ unread }) => unread == 1, + ).length + ) { + selfoss.entriesPage.setHasMore(true); + } } } - } - selfoss.db.lastUpdate = dataDate; + selfoss.db.lastUpdate = dataDate; - if (!storing) { - selfoss.dbOnline._syncDone(); - } - }).catch((error) => { - selfoss.dbOnline._syncDone(false); - selfoss.handleAjaxError(error).catch((error) => { - selfoss.app.showError(selfoss.app._('error_sync') + ' ' + error.message); + if (!storing) { + selfoss.dbOnline._syncDone(); + } + }) + .catch((error) => { + selfoss.dbOnline._syncDone(false); + selfoss.handleAjaxError(error).catch((error) => { + selfoss.app.showError( + selfoss.app._('error_sync') + ' ' + error.message, + ); + }); + }) + .finally(() => { + if (selfoss.dbOnline.syncing.promise) { + selfoss.dbOnline.syncing.request = null; + } }); - }).finally(() => { - if (selfoss.dbOnline.syncing.promise) { - selfoss.dbOnline.syncing.request = null; - } - }); return syncing; }, - /** * refresh current items. * * @return void */ getEntries(fetchParams, abortController) { - return itemsRequests.getItems({ - ...fetchParams, - itemsPerPage: selfoss.config.itemsPerPage - }, abortController).then((data) => { - selfoss.db.setOnline(); - - if (!selfoss.db.enableOffline.value) { - selfoss.db.lastSync = Date.now(); - selfoss.db.lastUpdate = data.lastUpdate; - } + return itemsRequests + .getItems( + { + ...fetchParams, + itemsPerPage: selfoss.config.itemsPerPage, + }, + abortController, + ) + .then((data) => { + selfoss.db.setOnline(); + + if (!selfoss.db.enableOffline.value) { + selfoss.db.lastSync = Date.now(); + selfoss.db.lastUpdate = data.lastUpdate; + } - selfoss.refreshStats(data.all, data.unread, data.starred); + selfoss.refreshStats(data.all, data.unread, data.starred); - // update tags - selfoss.app.setTags(data.tags); - selfoss.app.setTagsState(LoadingState.SUCCESS); + // update tags + selfoss.app.setTags(data.tags); + selfoss.app.setTagsState(LoadingState.SUCCESS); - if (typeof data.sources !== 'undefined' && selfoss.app.state.navSourcesExpanded) { - selfoss.app.setSources(data.sources); - selfoss.app.setSourcesState(LoadingState.SUCCESS); - } + if ( + typeof data.sources !== 'undefined' && + selfoss.app.state.navSourcesExpanded + ) { + selfoss.app.setSources(data.sources); + selfoss.app.setSourcesState(LoadingState.SUCCESS); + } - return { - entries: data.entries, - hasMore: data.hasMore - }; - }).catch((error) => { - if (error.name === 'AbortError') { - return; - } + return { + entries: data.entries, + hasMore: data.hasMore, + }; + }) + .catch((error) => { + if (error.name === 'AbortError') { + return; + } - return selfoss.handleAjaxError(error).then(() => { - return selfoss.dbOffline.getEntries(fetchParams); + return selfoss.handleAjaxError(error).then(() => { + return selfoss.dbOffline.getEntries(fetchParams); + }); }); - }); - } - - + }, }; diff --git a/client/js/selfoss-db.js b/client/js/selfoss-db.js index ead843825d..b3d8945975 100644 --- a/client/js/selfoss-db.js +++ b/client/js/selfoss-db.js @@ -14,22 +14,21 @@ import { OfflineStorageNotAvailableError } from './errors'; import { ValueListenable } from './helpers/ValueListenable'; selfoss.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(window.localStorage.getItem('enableOffline') === 'true'), + enableOffline: new ValueListenable( + window.localStorage.getItem('enableOffline') === 'true', + ), entryStatusNames: ['unread', 'starred'], userWaiting: true, - /** * last db timestamp known client side */ lastUpdate: null, - setOnline() { if (!selfoss.db.online) { selfoss.db.online = true; @@ -39,12 +38,10 @@ selfoss.db = { } }, - tryOnline() { return selfoss.db.sync(true); }, - setOffline() { if (selfoss.db.storage && !selfoss.db.broken) { selfoss.dbOnline._syncDone(false); @@ -58,7 +55,6 @@ selfoss.db = { } }, - clear() { if (selfoss.db.storage) { window.localStorage.removeItem('offlineDays'); @@ -71,23 +67,30 @@ selfoss.db = { } }, - isValidTag(name) { - return selfoss.app.state.tags.length === 0 || selfoss.app.state.tags.find((tag) => tag.tag === name) !== undefined; + return ( + selfoss.app.state.tags.length === 0 || + selfoss.app.state.tags.find((tag) => tag.tag === name) !== undefined + ); }, - isValidSource(id) { - return selfoss.app.state.sources.length === 0 || selfoss.app.state.sources.find((source) => source.id === id) !== undefined; + return ( + selfoss.app.state.sources.length === 0 || + 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; - const shouldSync = force || selfoss.dbOffline.needsSync || lastUpdateIsOld; + const lastUpdateIsOld = + selfoss.db.lastUpdate === null || + selfoss.db.lastSync === null || + Date.now() - selfoss.db.lastSync > 5 * 60 * 1000; + const shouldSync = + force || selfoss.dbOffline.needsSync || lastUpdateIsOld; if (selfoss.isAllowedToRead() && selfoss.isOnline() && shouldSync) { if (selfoss.db.enableOffline.value) { return selfoss.dbOffline.sendNewStatuses(); @@ -97,5 +100,5 @@ selfoss.db = { } else { return Promise.resolve(); // ensure any chained function runs } - } + }, }; diff --git a/client/js/sharers.jsx b/client/js/sharers.jsx index cb2cfc7077..8206408c06 100644 --- a/client/js/sharers.jsx +++ b/client/js/sharers.jsx @@ -8,132 +8,196 @@ function materializeSharerIcon(sharer) { const { icon } = sharer; return { ...sharer, - icon: typeof icon === 'string' && icon.includes('<') ? : icon, + icon: + typeof icon === 'string' && icon.includes('<') ? ( + + ) : ( + icon + ), }; } export function useSharers({ configuration, _ }) { - return useMemo( - () => { - const availableSharers = { - 'a': { - label: _('share_native_label'), - icon: , - action: ({url, title}) => { - navigator.share({ + return useMemo(() => { + const availableSharers = { + a: { + label: _('share_native_label'), + icon: , + action: ({ url, title }) => { + navigator + .share({ title, - url - }).catch((e) => { + url, + }) + .catch((e) => { if (e.name === 'AbortError') { - selfoss.app.showError(_('error_share_native_abort')); + selfoss.app.showError( + _('error_share_native_abort'), + ); } else { selfoss.app.showError(_('error_share_native')); } }); - }, - available: 'share' in navigator, }, + available: 'share' in navigator, + }, - 'd': { - label: _('share_diaspora_label'), - icon: , - action: ({url, title}) => { - window.open('https://share.diasporafoundation.org/?url=' + encodeURIComponent(url) + '&title=' + encodeURIComponent(title), undefined, 'noreferrer'); - }, + d: { + label: _('share_diaspora_label'), + icon: , + action: ({ url, title }) => { + window.open( + 'https://share.diasporafoundation.org/?url=' + + encodeURIComponent(url) + + '&title=' + + encodeURIComponent(title), + undefined, + 'noreferrer', + ); }, + }, - 't': { - label: _('share_twitter_label'), - icon: , - action: ({url, title}) => { - window.open('https://twitter.com/intent/tweet?source=webclient&text=' + encodeURIComponent(title) + ' ' + encodeURIComponent(url), undefined, 'noreferrer'); - }, + t: { + label: _('share_twitter_label'), + icon: , + action: ({ url, title }) => { + window.open( + 'https://twitter.com/intent/tweet?source=webclient&text=' + + encodeURIComponent(title) + + ' ' + + encodeURIComponent(url), + undefined, + 'noreferrer', + ); }, + }, - 'f': { - label: _('share_facebook_label'), - icon: , - action: ({url, title}) => { - window.open('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '&t=' + encodeURIComponent(title), undefined, 'noreferrer'); - }, + f: { + label: _('share_facebook_label'), + icon: , + action: ({ url, title }) => { + window.open( + 'https://www.facebook.com/sharer/sharer.php?u=' + + encodeURIComponent(url) + + '&t=' + + encodeURIComponent(title), + undefined, + 'noreferrer', + ); }, + }, - 'm': { - label: _('share_mastodon_label'), - icon: , - action: ({url, title}) => { - window.open(configuration.mastodon + '/share?text=' + encodeURIComponent('"' + title + '"\n' + url), undefined, 'noreferrer'); - }, - available: configuration.mastodon !== null, + m: { + label: _('share_mastodon_label'), + icon: , + action: ({ url, title }) => { + window.open( + configuration.mastodon + + '/share?text=' + + encodeURIComponent('"' + title + '"\n' + url), + undefined, + 'noreferrer', + ); }, + available: configuration.mastodon !== null, + }, - 'p': { - label: _('share_pocket_label'), - icon: , - action: ({url, title}) => { - window.open('https://getpocket.com/save?url=' + encodeURIComponent(url) + '&title=' + encodeURIComponent(title), undefined, 'noreferrer'); - }, + p: { + label: _('share_pocket_label'), + icon: , + action: ({ url, title }) => { + window.open( + 'https://getpocket.com/save?url=' + + encodeURIComponent(url) + + '&title=' + + encodeURIComponent(title), + undefined, + 'noreferrer', + ); }, + }, - 'w': { - label: _('share_wallabag_label'), - icon: , - action: ({url}) => { - if (configuration.wallabag.version === 2) { - window.open(configuration.wallabag.url + '/bookmarklet?url=' + encodeURIComponent(url), undefined, 'noreferrer'); - } else { - window.open(configuration.wallabag.url + '/?action=add&url=' + btoa(url), undefined, 'noreferrer'); - } - }, - available: configuration.wallabag !== null, + w: { + label: _('share_wallabag_label'), + icon: , + action: ({ url }) => { + if (configuration.wallabag.version === 2) { + window.open( + configuration.wallabag.url + + '/bookmarklet?url=' + + encodeURIComponent(url), + undefined, + 'noreferrer', + ); + } else { + window.open( + configuration.wallabag.url + + '/?action=add&url=' + + btoa(url), + undefined, + 'noreferrer', + ); + } }, + available: configuration.wallabag !== null, + }, - 's': { - label: _('share_wordpress_label'), - icon: , - action: ({url, title}) => { - window.open(configuration.wordpress + '/wp-admin/press-this.php?u=' + encodeURIComponent(url) + '&t=' + encodeURIComponent(title), undefined, 'noreferrer'); - }, - available: configuration.wordpress !== null, + s: { + label: _('share_wordpress_label'), + icon: , + action: ({ url, title }) => { + window.open( + configuration.wordpress + + '/wp-admin/press-this.php?u=' + + encodeURIComponent(url) + + '&t=' + + encodeURIComponent(title), + undefined, + 'noreferrer', + ); }, + available: configuration.wordpress !== null, + }, - 'e': { - label: _('share_mail_label'), - icon: , - action: ({url, title}) => { - document.location.href = 'mailto:?body=' + encodeURIComponent(url) + '&subject=' + encodeURIComponent(title); - }, + e: { + label: _('share_mail_label'), + icon: , + action: ({ url, title }) => { + document.location.href = + 'mailto:?body=' + + encodeURIComponent(url) + + '&subject=' + + encodeURIComponent(title); }, + }, - 'c': { - label: _('share_copy_label'), - icon: , - action: ({url}) => { - navigator.clipboard.writeText(url).then(() => { - selfoss.app.showMessage(_('info_url_copied')); - }); - }, + c: { + label: _('share_copy_label'), + icon: , + action: ({ url }) => { + navigator.clipboard.writeText(url).then(() => { + selfoss.app.showMessage(_('info_url_copied')); + }); }, + }, - ...map(materializeSharerIcon, selfoss.customSharers ?? {}), - }; + ...map(materializeSharerIcon, selfoss.customSharers ?? {}), + }; - const enabledSharers = []; - for (const letter of configuration.share) { - const sharer = availableSharers[letter]; - if (sharer !== undefined && (sharer.available ?? true)) { - const { label, icon, action } = sharer; - enabledSharers.push({ - key: letter, - label, - icon, - action, - }); - } + const enabledSharers = []; + for (const letter of configuration.share) { + const sharer = availableSharers[letter]; + if (sharer !== undefined && (sharer.available ?? true)) { + const { label, icon, action } = sharer; + enabledSharers.push({ + key: letter, + label, + icon, + action, + }); } + } - return enabledSharers; - }, - [configuration, _], - ); + return enabledSharers; + }, [configuration, _]); } diff --git a/client/js/shortcuts.js b/client/js/shortcuts.js index a9a2e34ab8..2bb1da4481 100644 --- a/client/js/shortcuts.js +++ b/client/js/shortcuts.js @@ -14,7 +14,11 @@ function ignoreWhenInteracting(handler) { // Ignore shortcuts when on input elements. // https://github.com/jamiebuilds/tinykeys/issues/17 const active = document.activeElement; - const enteringText = active instanceof HTMLElement && (active.isContentEditable || active.tagName === 'INPUT' || active.tagName === 'TEXTAREA'); + const enteringText = + active instanceof HTMLElement && + (active.isContentEditable || + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA'); if (!enteringText) { return handler(event); } @@ -27,25 +31,25 @@ function ignoreWhenInteracting(handler) { export default function makeShortcuts() { return tinykeys(document, { // 'space': next article - 'Space': ignoreWhenInteracting((event) => { + Space: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.jumpToNext(); }), // 'n': next article - 'n': ignoreWhenInteracting((event) => { + n: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.NEXT, false); }), // 'right cursor': next article - 'ArrowRight': ignoreWhenInteracting((event) => { + ArrowRight: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.entryNav(Direction.NEXT); }), // 'j': next article - 'j': ignoreWhenInteracting((event) => { + j: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.NEXT, true); }), @@ -57,37 +61,37 @@ export default function makeShortcuts() { }), // 'p': previous article - 'p': ignoreWhenInteracting((event) => { + p: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.PREV, false); }), // 'left': previous article - 'ArrowLeft': ignoreWhenInteracting((event) => { + ArrowLeft: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.entryNav(Direction.PREV); }), // 'k': previous article - 'k': ignoreWhenInteracting((event) => { + k: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.nextPrev(Direction.PREV, true); }), // 's': star/unstar - 's': ignoreWhenInteracting((event) => { + s: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedStarred(); }), // 'm': mark/unmark - 'm': ignoreWhenInteracting((event) => { + m: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedRead(); }), // 'o': open/close entry - 'o': ignoreWhenInteracting((event) => { + o: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.toggleSelectedExpanded(); }), @@ -99,7 +103,7 @@ export default function makeShortcuts() { }), // 'v': open target - 'v': ignoreWhenInteracting((event) => { + v: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.openSelectedTarget(); }), @@ -111,7 +115,7 @@ export default function makeShortcuts() { }), // 'r': Reload the current view - 'r': ignoreWhenInteracting((event) => { + r: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.reload(); }), @@ -129,7 +133,7 @@ export default function makeShortcuts() { }), // 't': throw (mark as read & open next) - 't': ignoreWhenInteracting((event) => { + t: ignoreWhenInteracting((event) => { event.preventDefault(); selfoss.entriesPage?.throw(Direction.NEXT); }), @@ -156,6 +160,6 @@ export default function makeShortcuts() { 'Shift+s': ignoreWhenInteracting((event) => { event.preventDefault(); document.querySelector('#nav-filter-starred').click(); - }) + }), }); } diff --git a/client/js/templates/App.jsx b/client/js/templates/App.jsx index 88d88e3203..0d803b4094 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.jsx @@ -1,9 +1,4 @@ -import React, { - useCallback, - useContext, - useEffect, - useState, -} from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import nullable from 'prop-types-nullable'; import { @@ -36,7 +31,6 @@ import { LoadingState } from '../requests/LoadingState'; import * as sourceRequests from '../requests/sources'; import locales from '../locales'; - function handleNavToggle({ event, setNavExpanded }) { event.preventDefault(); @@ -70,18 +64,20 @@ function Message({ message }) { } }, [message]); - return ( - message !== null ? -
- {message.message} - {message.actions.map(({ label, callback }, index) => ( - - ))} -
- : null - ); + return message !== null ? ( +
+ {message.message} + {message.actions.map(({ label, callback }, index) => ( + + ))} +
+ ) : null; } Message.propTypes = { @@ -91,29 +87,24 @@ Message.propTypes = { function NotFound() { const location = useLocation(); const _ = useContext(LocalizationContext); - return ( -

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

- ); + return

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

; } -function CheckAuthorization({ - isAllowed, - returnLocation, - _, - children, -}) { +function CheckAuthorization({ isAllowed, returnLocation, _, children }) { const history = useHistory(); if (!isAllowed) { - const [preLink, inLink, postLink] = _('error_unauthorized').split(/\{(?:link_begin|link_end)\}/); + const [preLink, inLink, postLink] = _('error_unauthorized').split( + /\{(?:link_begin|link_end)\}/, + ); history.push('/sign/in', { returnLocation, }); return (

- {preLink}{inLink}{postLink} + {preLink} + {inLink} + {postLink}

); } else { @@ -175,21 +166,20 @@ function PureApp({ const menuButtonOnClick = useCallback( (event) => handleNavToggle({ event, setNavExpanded }), - [] + [], ); - const entriesRef = useCallback( - (entriesPage) => { - setEntriesPage(entriesPage); - selfoss.entriesPage = entriesPage; - }, - [] - ); + const entriesRef = useCallback((entriesPage) => { + setEntriesPage(entriesPage); + selfoss.entriesPage = entriesPage; + }, []); const [title, setTitle] = useState(null); const [globalUnreadCount, setGlobalUnreadCount] = useState(null); useEffect(() => { - document.title = (title ?? configuration.htmlTitle) + ((globalUnreadCount ?? 0) > 0 ? ` (${globalUnreadCount})` : ''); + document.title = + (title ?? configuration.htmlTitle) + + ((globalUnreadCount ?? 0) > 0 ? ` (${globalUnreadCount})` : ''); }, [configuration, title, globalUnreadCount]); const _ = useContext(LocalizationContext); @@ -205,9 +195,7 @@ function PureApp({ {/* menu open for smartphone */}
- +
@@ -218,9 +206,7 @@ function PureApp({ _={_} >
- +
@@ -232,23 +218,42 @@ function PureApp({ _={_} >
- +
- +