diff --git a/client/eslint.config.js b/client/eslint.config.js index 8df53bfb2a..cc94ebd7f2 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,13 +39,16 @@ const config = { ], 'unicode-bom': 'error', + + '@typescript-eslint/no-explicit-any': 0, }, }; -export default [ +export default tseslint.config( js.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReactHooks.configs.flat.recommended, eslintConfigPrettier, + tseslint.configs.recommended, config, -]; +); diff --git a/client/images/wallabag.js b/client/images/wallabag.ts similarity index 92% rename from client/images/wallabag.js rename to client/images/wallabag.ts index acd87bb8ac..871ef1c209 100644 --- a/client/images/wallabag.js +++ b/client/images/wallabag.ts @@ -1,8 +1,18 @@ -export default [ +import { IconPathData } from '@fortawesome/fontawesome-svg-core'; + +const WALLABAG_ICON: [ + number, // width + number, // height + string[], // ligatures + string, // unicode + IconPathData, // svgPathData +] = [ 124.155, 133.529, [], - 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', ]; + +export default WALLABAG_ICON; diff --git a/client/index.html b/client/index.html index ecb251cbbc..6000b9e682 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.js deleted file mode 100644 index af55ffd6f3..0000000000 --- a/client/js/Filter.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Object describing how feed items are filtered in the view. - * @enum {string} - */ -export const FilterType = { - NEWEST: 'newest', - UNREAD: 'unread', - STARRED: 'starred', -}; diff --git a/client/js/Filter.ts b/client/js/Filter.ts new file mode 100644 index 0000000000..138d6f0cee --- /dev/null +++ b/client/js/Filter.ts @@ -0,0 +1,8 @@ +/** + * Object describing how feed items are filtered in the view. + */ +export enum FilterType { + NEWEST = 'newest', + UNREAD = 'unread', + STARRED = 'starred', +} diff --git a/client/js/errors.js b/client/js/errors.ts similarity index 61% rename from client/js/errors.js rename to client/js/errors.ts index bc625ea06f..0ca40d9b47 100644 --- a/client/js/errors.js +++ b/client/js/errors.ts @@ -1,33 +1,44 @@ export class OfflineStorageNotAvailableError extends Error { - constructor(message = 'Offline storage is not available') { + public name: string; + + constructor(message: string = 'Offline storage is not available') { super(message); this.name = 'OfflineStorageNotAvailableError'; } } export class TimeoutError extends Error { - constructor(message) { + public name: string; + + constructor(message: string) { super(message); this.name = 'TimeoutError'; } } export class HttpError extends Error { - constructor(message) { + public name: string; + public response: Response; + + constructor(message: string) { super(message); this.name = 'HttpError'; } } export class LoginError extends Error { - constructor(message) { + public name: string; + + constructor(message: string) { super(message); this.name = 'LoginError'; } } export class UnexpectedStateError extends Error { - constructor(message) { + public name: string; + + constructor(message: string) { super(message); this.name = 'UnexpectedStateError'; } diff --git a/client/js/helpers/ValueListenable.js b/client/js/helpers/ValueListenable.ts similarity index 62% rename from client/js/helpers/ValueListenable.js rename to client/js/helpers/ValueListenable.ts index 0ee151fb57..be120f2cd5 100644 --- a/client/js/helpers/ValueListenable.js +++ b/client/js/helpers/ValueListenable.ts @@ -1,5 +1,7 @@ -export class ValueChangeEvent extends Event { - constructor(value) { +export class ValueChangeEvent extends Event { + public value: T; + + constructor(value: T) { super('change'); this.value = value; } @@ -8,14 +10,16 @@ export class ValueChangeEvent extends Event { /** * Object storing a value and allowing subscribing to its changes. */ -export class ValueListenable extends EventTarget { - constructor(value) { +export class ValueListenable extends EventTarget { + public value: T; + + 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/ajax.js b/client/js/helpers/ajax.ts similarity index 63% rename from client/js/helpers/ajax.js rename to client/js/helpers/ajax.ts index 14d6717b4f..7b3906a083 100644 --- a/client/js/helpers/ajax.js +++ b/client/js/helpers/ajax.ts @@ -1,52 +1,85 @@ import formurlencoded from 'form-urlencoded'; -import mergeDeepLeft from 'ramda/src/mergeDeepLeft.js'; -import pipe from 'ramda/src/pipe.js'; +import mergeDeepLeft from 'ramda/src/mergeDeepLeft'; +import pipe from 'ramda/src/pipe'; import { HttpError, TimeoutError } from '../errors'; +type Headers = { + [index: string]: string; +}; + +type FetchOptions = { + body?: BodyInit | null; + method?: 'GET' | 'POST' | 'DELETE'; + headers?: Headers; + abortController?: AbortController; + timeout?: number; + failOnHttpErrors?: boolean; + signal?: AbortSignal; + cache?: RequestCache; +}; + +interface Fetch { + (url: RequestInfo | URL, opts?: RequestInit): Promise; +} + +type AbortableFetchResult = { + controller: AbortController; + promise: Promise; +}; + +interface AbortableFetch { + (url: RequestInfo | URL, opts?: FetchOptions): AbortableFetchResult; +} + /** * 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 function rejectUnless( + pred: (response: Response) => boolean, +): (Response) => Response { + return (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 function rejectIfNotOkay(response: 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. @@ -76,9 +109,8 @@ export const liftToPromiseField = * Wrapper for fetch that makes it cancellable using AbortController. * @return {controller: AbortController, promise: Promise} */ -export const makeAbortableFetch = - (fetch) => - (url, opts = {}) => { +export function makeAbortableFetch(fetch: Fetch): AbortableFetch { + return (url: string, opts: FetchOptions = {}) => { const controller = opts.abortController || new AbortController(); const promise = fetch(url, { signal: controller.signal, @@ -87,14 +119,16 @@ export const makeAbortableFetch = return { controller, promise }; }; +} /** * Wrapper for abortable fetch that adds timeout support. - * @return {controller: AbortController, promise: Promise} + * @return */ -export const makeFetchWithTimeout = - (abortableFetch) => - (url, opts = {}) => { +export function makeFetchWithTimeout( + abortableFetch: AbortableFetch, +): AbortableFetch { + return (url: string, opts: FetchOptions = {}): AbortableFetchResult => { // offline db consistency requires ajax calls to fail reliably, // so we enforce a default timeout on ajax calls const { timeout = 60000, ...rest } = opts; @@ -104,7 +138,7 @@ export const makeFetchWithTimeout = 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) { + if (error.name === 'AbortError' && 'timedOut' in promise) { error = new TimeoutError( `Request timed out after ${timeout / 1000} seconds`, ); @@ -114,7 +148,7 @@ export const makeFetchWithTimeout = }); setTimeout(() => { - promise.timedOut = true; + (promise as { timedOut?: boolean }).timedOut = true; controller.abort(); }, timeout); @@ -123,14 +157,13 @@ export const makeFetchWithTimeout = return { controller, promise }; }; +} /** * Wrapper for fetch that makes it fail on HTTP errors. - * @return Promise */ -export const makeFetchFailOnHttpErrors = - (fetch) => - (url, opts = {}) => { +export function makeFetchFailOnHttpErrors(fetch: Fetch): Fetch { + return (url: string, opts: FetchOptions = {}): Promise => { const { failOnHttpErrors = true, ...rest } = opts; const promise = fetch(url, rest); @@ -140,13 +173,13 @@ export const makeFetchFailOnHttpErrors = return promise; }; +} /** * Wrapper for fetch that converts URLSearchParams body of GET requests to query string. */ -export const makeFetchSupportGetBody = - (fetch) => - (url, opts = {}) => { +export function makeFetchSupportGetBody(fetch: Fetch): Fetch { + return (url: string, opts: FetchOptions = {}) => { const { body, method, ...rest } = opts; let newUrl = url; @@ -162,18 +195,18 @@ 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); }; +} /** * Cancellable fetch with timeout support that rejects on HTTP errors. * In such case, the `response` will be member of the Error object. - * @return {controller: AbortController, promise: Promise} */ -export const fetch = pipe( +export const fetch: AbortableFetch = pipe( // Same as jQuery.ajax option('credentials', 'same-origin'), header('X-Requested-With', 'XMLHttpRequest'), @@ -184,19 +217,26 @@ export const fetch = pipe( makeFetchWithTimeout, )(window.fetch); -export const get = liftToPromiseField(option('method', 'GET'))(fetch); +export const get: AbortableFetch = liftToPromiseField(option('method', 'GET'))( + fetch, +); -export const post = liftToPromiseField(option('method', 'POST'))(fetch); +export const post: AbortableFetch = liftToPromiseField( + option('method', 'POST'), +)(fetch); -export const delete_ = liftToPromiseField(option('method', 'DELETE'))(fetch); +export const delete_: AbortableFetch = 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( +export function makeSearchParams(data: object): URLSearchParams { + return new URLSearchParams( formurlencoded(data, { ignorenull: true, }), ); +} diff --git a/client/js/helpers/authorizations.js b/client/js/helpers/authorizations.ts similarity index 67% rename from client/js/helpers/authorizations.js rename to client/js/helpers/authorizations.ts index d34555a3bf..1a1e602e7f 100644 --- a/client/js/helpers/authorizations.js +++ b/client/js/helpers/authorizations.ts @@ -1,23 +1,24 @@ import { useListenableValue } from './hooks'; import { useMemo } from 'react'; +import selfoss from '../selfoss-base'; -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.js b/client/js/helpers/color.ts similarity index 65% rename from client/js/helpers/color.js rename to client/js/helpers/color.ts index bcf85d85d4..97180ed759 100644 --- a/client/js/helpers/color.js +++ 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.js b/client/js/helpers/configuration.js deleted file mode 100644 index 230a596520..0000000000 --- a/client/js/helpers/configuration.js +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const ConfigurationContext = createContext(); diff --git a/client/js/helpers/hooks.js b/client/js/helpers/hooks.ts similarity index 69% rename from client/js/helpers/hooks.js rename to client/js/helpers/hooks.ts index 58ddd961be..11d14bea0d 100644 --- a/client/js/helpers/hooks.js +++ 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 { useLocation } from '../helpers/uri'; +import { ValueChangeEvent, 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,23 +33,20 @@ 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: ValueChangeEvent) => { setValue(event.value); }; // It might happen that values change between creating the component and setting up the event handlers. - listener({ value: valueListenable.value }); + listener(new ValueChangeEvent(valueListenable.value)); valueListenable.addEventListener('change', listener); diff --git a/client/js/helpers/i18n.js b/client/js/helpers/i18n.ts similarity index 95% rename from client/js/helpers/i18n.js rename to client/js/helpers/i18n.ts index 12b7554c65..016f210fcc 100644 --- a/client/js/helpers/i18n.js +++ 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; @@ -104,4 +107,4 @@ export function i18nFormat(translated, params) { return formatted; } -export const LocalizationContext = React.createContext(); +export const LocalizationContext = React.createContext(undefined); diff --git a/client/js/helpers/navigation.js b/client/js/helpers/navigation.ts similarity index 86% rename from client/js/helpers/navigation.js rename to client/js/helpers/navigation.ts index 93aadfc489..753a32787f 100644 --- a/client/js/helpers/navigation.js +++ 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.js b/client/js/helpers/uri.ts similarity index 62% rename from client/js/helpers/uri.js rename to client/js/helpers/uri.ts index 0a362dcbe5..c0f1204b06 100644 --- a/client/js/helpers/uri.js +++ b/client/js/helpers/uri.ts @@ -1,14 +1,28 @@ -import { useLocation, useMatch } from 'react-router'; +import { Location as LocationGeneric } from 'history'; +import { useLocation as useLocationGeneric, useMatch } from 'react-router'; import { FilterType } from '../Filter'; +export type LocationState = { + error?: string; + returnLocation?: string; + forceReload?: number; +}; +export type Params = { + filter: FilterType; + category: string; + id: number | null; +}; + +export type Location = LocationGeneric; + +export function useLocation(): Location { + return useLocationGeneric(); +} + /** * 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 +36,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) { @@ -36,11 +46,20 @@ export function filterTypeToString(type) { return 'starred'; } } -function generatePath({ filter, category, id }) { + +function generatePath({ + filter, + category, + id, +}: { + filter: string; + category: string; + id: string | null; +}): string { return `/${filter}/${category}${id ? `/${id}` : ''}`; } -export function useEntriesParams() { +export function useEntriesParams(): Params | null { const match = useMatch(':filter/:category/:id?'); if (match === null) { @@ -56,16 +75,27 @@ export function useEntriesParams() { return null; } - return params; + return { + filter: filterTypeFromString(params.filter), + category: params.category, + id: params.id === undefined ? null : parseInt(params.id, 10), + }; } +type EntriesLinkParams = { + filter?: FilterType; + category?: string; + id?: string; + 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; + let path: string; if (location.pathname.match(/^\/(newest|unread|starred)\//) !== null) { const [, ...segments] = location.pathname.split('/'); @@ -97,13 +127,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): LocationState { const state = location.state ?? {}; return { @@ -112,7 +145,7 @@ export function forceReload(location) { }; } -export function useForceReload() { +export function useForceReload(): LocationState { const location = useLocation(); return forceReload(location); } diff --git a/client/js/icons.js b/client/js/icons.ts similarity index 94% rename from client/js/icons.js rename to client/js/icons.ts index 7e2fb11f71..e71b4a893b 100644 --- a/client/js/icons.js +++ b/client/js/icons.ts @@ -31,11 +31,16 @@ import { faStar } from '@fortawesome/free-solid-svg-icons/faStar'; import { faSyncAlt } from '@fortawesome/free-solid-svg-icons/faSyncAlt'; import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; import { faWifi } from '@fortawesome/free-solid-svg-icons/faWifi'; +import { + IconDefinition, + IconName, + IconPrefix, +} from '@fortawesome/fontawesome-svg-core'; import wallabagIcon from '../images/wallabag'; -export const wallabag = { - prefix: 'fac', - iconName: 'wallabag', +export const wallabag: IconDefinition = { + prefix: 'fac', + iconName: 'wallabag', icon: wallabagIcon, }; diff --git a/client/js/index.js b/client/js/index.js deleted file mode 100644 index 9c4c402a25..0000000000 --- a/client/js/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import 'regenerator-runtime/runtime'; -import selfoss from './selfoss-base'; -import './selfoss-db-online'; -import './selfoss-db-offline'; -import './selfoss-db'; - -selfoss.init(); - -// make selfoss available in console for debugging -window.selfoss = selfoss; diff --git a/client/js/index.ts b/client/js/index.ts new file mode 100644 index 0000000000..70cc5c759a --- /dev/null +++ b/client/js/index.ts @@ -0,0 +1,13 @@ +import 'regenerator-runtime/runtime'; +import base from './selfoss-base'; + +base.init(); + +declare global { + interface Window { + selfoss: base; + } +} + +// make selfoss available in console for debugging +window.selfoss = base; 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/model/Configuration.ts b/client/js/model/Configuration.ts new file mode 100644 index 0000000000..969d11be5d --- /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/model/OfflineDb.ts b/client/js/model/OfflineDb.ts new file mode 100644 index 0000000000..a1b7019b91 --- /dev/null +++ b/client/js/model/OfflineDb.ts @@ -0,0 +1,58 @@ +import Dexie from 'dexie'; + +export interface Entry { + id: number; + datetime: Date; + unread: boolean; + starred: boolean; +} + +export interface Status { + id?: number; // Primary key. Optional (autoincremented). + entryId: number; + name: string; + value: boolean; + datetime: Date; +} + +export interface Stamp { + name: string; + datetime: Date; +} + +export interface Stat { + name: string; + value: number; +} + +export interface Tag { + name: string; +} + +export 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/requests/LoadingState.js b/client/js/requests/LoadingState.js deleted file mode 100644 index e08d1ce23f..0000000000 --- a/client/js/requests/LoadingState.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Object describing what state a request is in. - * @enum {string} - */ -export const LoadingState = { - INITIAL: 'initial', - LOADING: 'loading', - SUCCESS: 'success', - FAILURE: 'failure', -}; diff --git a/client/js/requests/LoadingState.ts b/client/js/requests/LoadingState.ts new file mode 100644 index 0000000000..5e6be37bc6 --- /dev/null +++ b/client/js/requests/LoadingState.ts @@ -0,0 +1,9 @@ +/** + * Object describing what state a request is in. + */ +export enum LoadingState { + INITIAL = 'initial', + LOADING = 'loading', + SUCCESS = 'success', + FAILURE = 'failure', +} diff --git a/client/js/requests/common.js b/client/js/requests/common.ts similarity index 72% rename from client/js/requests/common.js rename to client/js/requests/common.ts index a89be52ac1..2bf96da03d 100644 --- a/client/js/requests/common.js +++ b/client/js/requests/common.ts @@ -1,17 +1,30 @@ import { LoginError } from '../errors'; import * as ajax from '../helpers/ajax'; +import { Configuration } from '../model/Configuration'; export class PasswordHashingError extends Error { - constructor(message) { + public name: string; + + 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 @@ -20,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), @@ -41,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 }), @@ -56,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); @@ -84,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.js b/client/js/requests/items.js deleted file mode 100644 index 193c1ac6ad..0000000000 --- a/client/js/requests/items.js +++ /dev/null @@ -1,124 +0,0 @@ -import * as ajax from '../helpers/ajax'; -import { unescape } from 'html-escaper'; - -function safeDate(datetimeString) { - const date = new Date(datetimeString); - - if (isNaN(date.valueOf())) { - throw new Error(`Invalid date detected: “${datetimeString}”`); - } else { - return date; - } -} - -/** - * 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()); -} - -/** - * Star or unstar item with given id. - */ -export function starr(id, starr) { - return ajax.post(`${starr ? 'starr' : 'unstarr'}/${id}`).promise; -} - -/** - * Mark item with given id as (un)read. - */ -export function mark(id, read) { - return ajax.post(`${read ? 'unmark' : 'mark'}/${id}`).promise; -} - -/** - * Converts some values like dates in an entry into a objects. - */ -function enrichEntry(entry) { - return { - ...entry, - link: unescape(entry.link), - datetime: safeDate(entry.datetime), - updatetime: entry.updatetime - ? safeDate(entry.updatetime) - : entry.updatetime, - }; -} - -/** - * Converts some values like dates in response into a objects. - */ -function enrichItemsResponse(data) { - return { - ...data, - lastUpdate: data.lastUpdate - ? safeDate(data.lastUpdate) - : data.lastUpdate, - // in getItems - entries: data.entries?.map(enrichEntry), - // in sync - newItems: data.newItems?.map(enrichEntry), - }; -} - -/** - * 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); -} - -/** - * Synchronize changes between client and server. - */ -export function sync(updatedStatuses, syncParams) { - const params = { - ...syncParams, - updatedStatuses: syncParams.updatedStatuses - ? syncParams.updatedStatuses.map((status) => { - 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(); - } - - const { controller, promise } = ajax.fetch('items/sync', { - method: updatedStatuses ? 'POST' : 'GET', - body: ajax.makeSearchParams(params), - }); - - return { - controller, - promise: promise - .then((response) => response.json()) - .then(enrichItemsResponse), - }; -} diff --git a/client/js/requests/items.ts b/client/js/requests/items.ts new file mode 100644 index 0000000000..98badd7235 --- /dev/null +++ b/client/js/requests/items.ts @@ -0,0 +1,271 @@ +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: string): Date { + const date = new Date(datetimeString); + + if (isNaN(date.valueOf())) { + throw new Error(`Invalid date detected: “${datetimeString}”`); + } else { + return date; + } +} + +/** + * Mark items with given ids as read. + */ +export function markAll(ids: number[]): Promise { + return ajax + .post('mark', { + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + body: JSON.stringify(ids), + }) + .promise.then((response) => response.json()); +} + +/** + * Star or unstar item with given id. + */ +export function starr(id: number, starr: boolean): Promise { + return ajax + .post(`${starr ? 'starr' : 'unstarr'}/${id}`) + .promise.then((response) => response.json()); +} + +/** + * Mark item with given id as (un)read. + */ +export function mark(id: number, read: boolean): Promise { + return ajax + .post(`${read ? 'unmark' : 'mark'}/${id}`) + .promise.then((response) => response.json()); +} + +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 enrichItem(entry: RawResponseItem): ResponseItem { + return { + ...entry, + link: unescape(entry.link), + datetime: safeDate(entry.datetime), + 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: RawItemsResponse): ItemsResponse { + return { + ...data, + lastUpdate: data.lastUpdate !== null ? safeDate(data.lastUpdate) : null, + entries: data.entries.map(enrichItem), + }; +} + +type QueryFilter = { + fromDatetime?: Date; + itemsPerPage?: number; +}; + +/** + * Get all items matching given filter. + */ +export function getItems( + filter: QueryFilter, + abortController?: AbortController, +): Promise { + return ajax + .get('', { + body: ajax.makeSearchParams({ + ...filter, + fromDatetime: filter.fromDatetime + ? filter.fromDatetime.toISOString() + : filter.fromDatetime, + }), + abortController, + }) + .promise.then((response: Response) => response.json()) + .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; color: string }; + +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: Array, + syncParams: SyncParams, +): { controller: AbortController; promise: Promise } { + const params = { + ...syncParams, + updatedStatuses: syncParams.updatedStatuses + ? syncParams.updatedStatuses.map((status: StatusUpdate) => { + return { + ...status, + datetime: status.datetime.toISOString(), + }; + }) + : syncParams.updatedStatuses, + + 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', + body: ajax.makeSearchParams(params), + }); + + return { + controller, + promise: promise + .then((response: Response) => response.json()) + .then(enrichSyncResponse), + }; +} diff --git a/client/js/requests/sources.js b/client/js/requests/sources.js deleted file mode 100644 index 5c7c308e66..0000000000 --- a/client/js/requests/sources.js +++ /dev/null @@ -1,91 +0,0 @@ -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()); -} - -/** - * Triggers an update of the source with given ID. - */ -export function refreshSingle(id) { - return ajax.post('source/' + id + '/update', { - timeout: 0, - }).promise; -} - -/** - * 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()); -} - -/** - * Removes source with given ID. - */ -export function remove(id) { - return ajax.delete_(`source/${id}`).promise; -} - -/** - * Gets all sources. - */ -export function getAllSources(abortController) { - return ajax - .get('sources', { - abortController, - }) - .promise.then((response) => response.json()); -} - -/** - * Gets list of supported spouts and their paramaters. - */ -export function getSpouts() { - return ajax - .get('sources/spouts') - .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()); -} - -/** - * Gets source unread stats. - */ -export function getStats() { - return ajax - .get('sources/stats') - .promise.then((response) => response.json()); -} diff --git a/client/js/requests/sources.ts b/client/js/requests/sources.ts new file mode 100644 index 0000000000..6645638c5a --- /dev/null +++ b/client/js/requests/sources.ts @@ -0,0 +1,187 @@ +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: number, values: object): Promise { + 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()); +} + +/** + * Triggers an update of the source with given ID. + */ +export function refreshSingle(id: number): Promise { + return ajax.post('source/' + id + '/update', { + timeout: 0, + }).promise; +} + +/** + * Triggers an update of all sources. + */ +export function refreshAll(): Promise { + return ajax + .get('update', { + headers: { + Accept: 'text/event-stream', + }, + timeout: 0, + }) + .promise.then((response) => response.text()); +} + +/** + * Removes source with given ID. + */ +export function remove(id: number): Promise { + return ajax + .delete_(`source/${id}`) + .promise.then((response) => response.json()); +} + +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; + }; +}; + +export type SourceWithIcon = { + id: number; + title: string; + tags: Array; + spout: string; + params: { [name: string]: string }; + filter: string | null; + error: string | null; + lastentry: number | null; + icon: string | null; +}; + +type AllSourcesResponse = { + spouts: { [key: string]: Spout }; + sources: Array; +}; + +/** + * Gets all sources. + */ +export function getAllSources( + abortController: AbortController, +): Promise { + return ajax + .get('sources', { + abortController, + }) + .promise.then((response) => response.json()); +} + +type SpoutsResponse = { + [key: string]: Spout; +}; + +/** + * Gets list of supported spouts and their paramaters. + */ +export function getSpouts(): Promise { + return ajax + .get('sources/spouts') + .promise.then((response) => response.json()); +} + +type SpoutParamsResponse = { + id: string; + spout: Spout; +}; + +/** + * Gets parameters for given spout. + */ +export function getSpoutParams( + spoutClass: string, +): Promise { + return ajax + .get('source/params', { + body: ajax.makeSearchParams({ spout: spoutClass }), + }) + .promise.then((res) => res.json()); +} + +/** + * Gets source unread stats. + */ +export function getStats(): Promise> { + return ajax + .get('sources/stats') + .promise.then((response) => response.json()); +} diff --git a/client/js/requests/tags.js b/client/js/requests/tags.ts similarity index 58% rename from client/js/requests/tags.js rename to client/js/requests/tags.ts index 7d38d139ee..6a51e4813f 100644 --- a/client/js/requests/tags.js +++ 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.js b/client/js/selfoss-base.ts similarity index 61% rename from client/js/selfoss-base.js rename to client/js/selfoss-base.ts index ad5b5d45aa..aab6194519 100644 --- a/client/js/selfoss-base.js +++ b/client/js/selfoss-base.ts @@ -4,8 +4,14 @@ 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'; +import DbOnline from './selfoss-db-online'; +import DbOffline from './selfoss-db-offline'; +import Db from './selfoss-db'; +import { NavigateFunction } from 'react-router'; +import { Sharer } from './sharers'; /** * base javascript application @@ -14,36 +20,43 @@ import { createApp } from './templates/App'; * @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); + + public static db: Db = new Db(); + public static dbOnline: DbOnline = new DbOnline(); + public static dbOffline: DbOffline = new DbOffline(); + + static navigate: NavigateFunction | undefined = undefined; + static config: Configuration; + static customSharers: { [key: string]: Sharer }; /** * initialize application */ - async init() { + 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; @@ -77,19 +90,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) { - 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) { @@ -122,21 +135,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) { + static attachApp(configuration: Configuration): void { document.getElementById('js-loading-message')?.remove(); const mainUi = document.createElement('div'); @@ -150,42 +163,48 @@ const selfoss = { root.render( createApp({ basePath, - appRef: (app) => { - selfoss.app = app; + appRef: (app: App) => { + this.app = app; }, configuration, }), ); - }, + } - loggedin: new ValueListenable(false), + public static loggedin = new ValueListenable(false); - setSession() { - window.localStorage.setItem('onlineSession', true); - selfoss.loggedin.update(true); - }, + static setSession(): void { + window.localStorage.setItem('onlineSession', 'true'); + this.loggedin.update(true); + } - clearSession() { + static clearSession(): void { window.localStorage.removeItem('onlineSession'); - selfoss.loggedin.update(false); - }, + this.loggedin.update(false); + } - hasSession() { - return selfoss.loggedin.value; - }, + static hasSession(): boolean { + return this.loggedin.value; + } /** * Try to log in using given credentials - * @return Promise */ - login({ configuration, username, password, enableOffline }) { - selfoss.db.enableOffline.update(enableOffline); + static login(props: { + configuration: Configuration; + username: string; + password: string; + enableOffline: boolean; + }): Promise { + const { configuration, username, password, enableOffline } = props; + + this.db.enableOffline.update(enableOffline); window.localStorage.setItem( 'enableOffline', - selfoss.db.enableOffline.value, + this.db.enableOffline.value.toString(), ); - if (!selfoss.db.enableOffline.value) { - selfoss.db.clear(); + if (!this.db.enableOffline.value) { + this.db.clear(); } const credentials = { @@ -193,15 +212,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( @@ -223,44 +242,41 @@ const selfoss = { ); } - selfoss.setupServiceWorker(); + this.setupServiceWorker(); } }); - }, + } - setupServiceWorker() { - 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(); }); 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) => { - selfoss.listenWaitingSW(reg, (reg) => { - selfoss.app.notifyNewVersion(() => { + this.listenWaitingSW(reg, (reg) => { + this.app.notifyNewVersion(() => { if (reg.waiting) { reg.waiting.postMessage('skipWaiting'); } }); }); }); - }, + } - async logout() { - 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) { @@ -274,180 +290,177 @@ 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. - * - * @returns {boolean} */ - isAllowedToRead() { + 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. - * - * @returns {boolean} */ - isAllowedToUpdate() { + 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. - * - * @returns {boolean} */ - isAllowedToWrite() { - 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. - * - * @returns {boolean} */ - isOnline() { - 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() { + 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() { + 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() { + 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 {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) { - 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. * - * @return void - * @param {Number} new unread stats + * @param unread new unread stats */ - refreshUnread(unread) { - selfoss.app.setUnreadItemsCount(unread); - }, + static refreshUnread(unread: number): void { + this.app.setUnreadItemsCount(unread); + } /** * refresh current tags. - * - * @return void */ - reloadTags() { - 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, tryOffline = true) { + static handleAjaxError( + error: Error, + tryOffline: boolean = true, + ): Promise { if (!(error instanceof HttpError || error instanceof TimeoutError)) { return Promise.reject(error); } - const httpCode = error?.response?.status || 0; + const httpCode = 'response' in error ? error.response.status : 0; if (tryOffline && httpCode != 403) { - return selfoss.db.setOffline(); + return this.db.setOffline(); } else { return Promise.reject(error); } - }, + } - listenWaitingSW(reg, callback) { - const awaitStateChange = () => { + static listenWaitingSW( + reg: ServiceWorkerRegistration, + callback: (reg: ServiceWorkerRegistration) => void, + ): void { + const awaitStateChange = (): void => { reg.installing.addEventListener('statechange', (event) => { - if (event.target.state === 'installed') { + // https://github.com/microsoft/TypeScript/issues/40153 + const sw = event.target as ServiceWorker; + if (sw.state === 'installed') { callback(reg); } }); @@ -461,10 +474,10 @@ const selfoss = { awaitStateChange(); reg.addEventListener('updatefound', awaitStateChange); } - }, + } // Include helpers for user scripts. - ajax, -}; + public static ajax = ajax; +} export default selfoss; diff --git a/client/js/selfoss-db-offline.js b/client/js/selfoss-db-offline.ts similarity index 69% rename from client/js/selfoss-db-offline.js rename to client/js/selfoss-db-offline.ts index 1fdc41a5c7..49c6712fbb 100644 --- a/client/js/selfoss-db-offline.js +++ b/client/js/selfoss-db-offline.ts @@ -1,38 +1,62 @@ import selfoss from './selfoss-base'; import { OfflineStorageNotAvailableError } from './errors'; -import Dexie from 'dexie'; +import Dexie, { + PromiseExtended, + Table, + Transaction, + TransactionMode, +} from 'dexie'; +import { OfflineDb, Entry } from './model/OfflineDb'; import { FilterType } from './Filter'; +import { FetchParams } from './selfoss-db-online'; -const ENTRY_STATUS_NAMES = ['unread', 'starred']; +export type ItemStatus = { + id: number; + unread?: boolean; + starred?: boolean; +}; + +function statToNumber(stat: boolean | undefined): number { + return stat === undefined ? 0 : stat ? 1 : -1; +} -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, - - lastItemId: null, - newerEntriesMissing: false, - shouldLoadEntriesOnline: false, - 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); - } + public newestGCedEntry: Date | null = null; + public offlineDays: number = 10; + + public lastItemId: number | null = null; + public newerEntriesMissing: boolean = false; + public shouldLoadEntriesOnline: boolean = false; + public olderEntriesOnline: boolean = false; + public needsSync: boolean; + + _tr( + mode: TransactionMode, + tables: Table[], + scope: (trans: Transaction) => PromiseLike | U, + ): PromiseExtended { + return selfoss.db.storage + .transaction(mode, tables, scope) + .catch((error) => { + selfoss.app.showError( + selfoss.app._('error_offline_storage', { + '0': error.message, + }), + ); + selfoss.db.broken = true; + selfoss.db.enableOffline.update(false); + selfoss.entriesPage?.reload(); - return Promise.reject(error); - }); - }, + // If this is a QuotaExceededError, garbage collect more + // entries and hope it helps. + if (error.name === Dexie.errnames.QuotaExceeded) { + this.GCEntries(true); + } + + return Promise.reject(error); + }); + } init() { if (!selfoss.db.enableOffline.value || selfoss.db.storage) { @@ -40,15 +64,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 }); @@ -62,7 +78,7 @@ selfoss.dbOffline = { 'r', [selfoss.db.storage.entries, selfoss.db.storage.stamps], () => { - selfoss.dbOffline._memLastItemId(); + this._memLastItemId(); selfoss.db.storage.stamps.get( 'lastItemsUpdate', (stamp) => { @@ -70,7 +86,7 @@ selfoss.dbOffline = { selfoss.db.lastUpdate = stamp.datetime; selfoss.dbOnline.firstSync = false; } else { - selfoss.dbOffline.shouldLoadEntriesOnline = true; + this.shouldLoadEntriesOnline = true; } }, ); @@ -78,18 +94,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, ); } @@ -100,15 +112,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, ), ); @@ -121,10 +133,10 @@ selfoss.dbOffline = { selfoss.app.showError( selfoss.app._( 'error_offline_storage_not_available', - [ - '', - '', - ], + { + '0': '', + '1': '', + }, ), ); } else { @@ -137,14 +149,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 @@ -152,28 +164,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) { @@ -181,15 +193,15 @@ 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, + (Date.now() - +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, + this.offlineDays.toString(), ); } @@ -205,16 +217,12 @@ selfoss.dbOffline = { !stamp || more || (stamp && - Date.now() - stamp.datetime > 24 * 3600 * 1000) + 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, + Date.now() - this.offlineDays * 24 * 3600 * 1000, ); selfoss.db.storage.entries @@ -225,12 +233,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(() => { @@ -241,8 +245,7 @@ selfoss.dbOffline = { }, { name: 'newestGCedEntry', - datetime: - selfoss.dbOffline.newestGCedEntry, + datetime: this.newestGCedEntry, }, ]); }); @@ -250,10 +253,10 @@ selfoss.dbOffline = { }); }, ); - }, + } - storeStats(stats) { - return selfoss.dbOffline._tr('rw', [selfoss.db.storage.stats], () => { + storeStats(stats: { [key: string]: number }): Promise { + return this._tr('rw', [selfoss.db.storage.stats], () => { for (const [name, value] of Object.entries(stats)) { selfoss.db.storage.stats.put({ name, @@ -261,10 +264,10 @@ selfoss.dbOffline = { }); } }); - }, + } - storeLastUpdate(lastUpdate) { - return selfoss.dbOffline._tr('rw', [selfoss.db.storage.stamps], () => { + storeLastUpdate(lastUpdate: Date): Promise { + return this._tr('rw', [selfoss.db.storage.stamps], () => { if (lastUpdate) { selfoss.db.storage.stamps.put({ name: 'lastItemsUpdate', @@ -272,9 +275,11 @@ selfoss.dbOffline = { }); } }); - }, + } - getEntries(fetchParams) { + getEntries( + fetchParams: FetchParams, + ): Promise<{ entries: Entry[]; hasMore: boolean }> { let hasMore = false; return selfoss.dbOffline ._tr('r', [selfoss.db.storage.entries], () => { @@ -337,11 +342,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 } @@ -358,12 +363,16 @@ 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 = {}; + const newStats = { + unread: 0, + starred: 0, + total: 0, + }; stats.forEach((stat) => { newStats[stat.name] = stat.value; }); @@ -374,10 +383,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 @@ -396,35 +405,37 @@ selfoss.dbOffline = { selfoss.app.refreshOfflineCounts(offlineCounts); }); }); - }, + } - enqueueStatuses(statuses) { + enqueueStatuses( + statuses: { entryId: number; name: string; value: boolean }[], + ): Promise { if (statuses) { - selfoss.dbOffline.needsSync = true; + this.needsSync = true; } const d = new Date(); const newQueuedStatuses = statuses.map((newStatus) => ({ - entryId: parseInt(newStatus.entryId), + entryId: newStatus.entryId, name: newStatus.name, value: newStatus.value, 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 @@ -443,14 +454,28 @@ 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) { + } + + /** + * Update `unread` and `starred` statuses of items in the offline database. + * + * @param dequee - also clear the local status updates queued to upload to server + * @param updateStats - also update local statistics + * + * Note: Do not use `updateStats` with `itemStatuses` obtained from the server. + * The server report cannot distinguish which of the status fields was changed + * so both will be considered changed. + */ + storeEntryStatuses( + itemStatuses: ItemStatus[], + dequeue: boolean = false, + updateStats: boolean = true, + ): Promise { return selfoss.dbOffline ._tr( 'rw', @@ -460,28 +485,27 @@ selfoss.dbOffline = { selfoss.db.storage.statusq, ], () => { - const statsDiff = {}; + const statsDiff = { unread: 0, starred: 0 }; // update entries statuses itemStatuses.forEach((itemStatus) => { - const newStatus = {}; - - ENTRY_STATUS_NAMES.forEach((statusName) => { - if (statusName in itemStatus) { - statsDiff[statusName] = 0; - newStatus[statusName] = itemStatus[statusName]; - - if (updateStats) { - if (itemStatus[statusName]) { - statsDiff[statusName]++; - } else { - statsDiff[statusName]--; - } - } - } - }); + const newStatus = { + ...('unread' in itemStatus && { + unread: itemStatus.unread, + }), + ...('starred' in itemStatus && { + starred: itemStatus.starred, + }), + }; + + if (updateStats) { + statsDiff.unread += statToNumber(itemStatus.unread); + statsDiff.starred += statToNumber( + itemStatus.starred, + ); + } - const id = parseInt(itemStatus.id); + const id = itemStatus.id; selfoss.db.storage.entries.get(id).then( () => { selfoss.db.storage.entries.update( @@ -492,7 +516,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; }, ); @@ -517,27 +541,27 @@ selfoss.dbOffline = { } }, ) - .then(selfoss.dbOffline.refreshStats); - }, + .then(this.refreshStats); + } - entriesMark(itemIds, unread) { + entriesMark(itemIds: number[], unread: boolean): Promise { 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); - }, + entryMark(itemId: number, unread: boolean): Promise { + return this.entriesMark([itemId], unread); + } - entryStar(itemId, starred) { - return selfoss.dbOffline.storeEntryStatuses([ + entryStar(itemId: number, starred: boolean): Promise { + return this.storeEntryStatuses([ { id: itemId, starred, }, ]); - }, -}; + } +} diff --git a/client/js/selfoss-db-online.js b/client/js/selfoss-db-online.ts similarity index 72% rename from client/js/selfoss-db-online.js rename to client/js/selfoss-db-online.ts index 0b9370358f..d9ee51a759 100644 --- a/client/js/selfoss-db-online.js +++ b/client/js/selfoss-db-online.ts @@ -2,93 +2,113 @@ import selfoss from './selfoss-base'; import * as itemsRequests from './requests/items'; import { LoadingState } from './requests/LoadingState'; import { FilterType } from './Filter'; +import { StatusUpdate, SyncParams, SyncResponse } from './requests/items'; +import { Entry } from './model/OfflineDb'; + +export type FetchParams = { + type: FilterType; + tag: string | null; + source: number | null; + extraIds: number[]; + sourcesNav: boolean; + search: string | null; + fromDatetime: Date | null; + fromId: number | null; +}; -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 = true) { - if (selfoss.dbOnline.syncing.promise) { + _syncDone(success: boolean = true): void { + 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. - * - * @return Promise */ - sync(updatedStatuses, chained) { - 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; } - const syncParams = { + const syncParams: SyncParams = { since: selfoss.db.lastUpdate, tags: true, sources: selfoss.app.state.navSourcesExpanded || undefined, @@ -105,19 +125,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; @@ -145,7 +162,7 @@ selfoss.dbOnline = { .storeEntries(data.newItems) .then(() => { selfoss.dbOffline.storeLastUpdate(dataDate); - selfoss.dbOnline._syncDone(); + this._syncDone(); }); } @@ -176,7 +193,7 @@ selfoss.dbOnline = { } } - if (!selfoss.dbOnline.statsDirty && 'stats' in data) { + if (!this.statsDirty && 'stats' in data) { selfoss.refreshStats( data.stats.total, data.stats.unread, @@ -234,11 +251,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, @@ -246,20 +263,21 @@ 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. - * - * @return void */ - getEntries(fetchParams, abortController) { + getEntries( + fetchParams: FetchParams, + abortController: AbortController, + ): Promise<{ entries: Entry[]; hasMore: boolean }> { return itemsRequests .getItems( { @@ -304,5 +322,5 @@ selfoss.dbOnline = { return selfoss.dbOffline.getEntries(fetchParams); }); }); - }, -}; + } +} diff --git a/client/js/selfoss-db.js b/client/js/selfoss-db.ts similarity index 67% rename from client/js/selfoss-db.js rename to client/js/selfoss-db.ts index e3bbc7b6d5..7a89566c81 100644 --- a/client/js/selfoss-db.js +++ 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() { - return selfoss.db.sync(true); - }, + tryOnline(): Promise { + return this.sync(true); + } - setOffline() { - if (selfoss.db.storage && !selfoss.db.broken) { + setOffline(): Promise { + 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 } - }, -}; + } +} diff --git a/client/js/sharers.jsx b/client/js/sharers.tsx similarity index 89% rename from client/js/sharers.jsx rename to client/js/sharers.tsx index 8206408c06..46a88b7b4c 100644 --- a/client/js/sharers.jsx +++ b/client/js/sharers.tsx @@ -1,13 +1,29 @@ import React, { useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import map from 'ramda/src/map.js'; +import map from 'ramda/src/map'; import selfoss from './selfoss-base'; import * as icons from './icons'; +import { Configuration } from './model/Configuration'; -function materializeSharerIcon(sharer) { +export type Sharer = { + label: string; + icon: string | React.JSX.Element; + action: (params: { url: string; title: string }) => void; + available?: boolean; +}; + +export type EnabledSharer = { + key: string; + label: string; + icon: string | React.JSX.Element; + action: (params: { url: string; title: string }) => void; +}; + +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 +33,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.js b/client/js/shortcuts.ts similarity index 60% rename from client/js/shortcuts.js rename to client/js/shortcuts.ts index 2bb1da4481..91e4241620 100644 --- a/client/js/shortcuts.js +++ b/client/js/shortcuts.ts @@ -1,12 +1,17 @@ import { tinykeys } from 'tinykeys'; +import selfoss from './selfoss-base'; 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 +25,7 @@ function ignoreWhenInteracting(handler) { active.tagName === 'INPUT' || active.tagName === 'TEXTAREA'); if (!enteringText) { - return handler(event); + handler(event); } }; } @@ -28,138 +33,144 @@ 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(); + 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(); + 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(); + 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(); + 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(); + document + .querySelector('#nav-filter-starred') + .click(); }), }); } diff --git a/client/js/templates/App.jsx b/client/js/templates/App.tsx similarity index 79% rename from client/js/templates/App.jsx rename to client/js/templates/App.tsx index 2af9cbdeb9..dfed6ab30f 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.tsx @@ -1,6 +1,11 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import nullable from 'prop-types-nullable'; +import React, { + Dispatch, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { BrowserRouter as Router, Routes, @@ -8,31 +13,47 @@ import { Link, Navigate, useNavigate, - useLocation, } from 'react-router'; import { useEffectEvent } from 'use-effect-event'; 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'; 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'; 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'; -import { useEntriesParams } from '../helpers/uri'; +import { useEntriesParams, useLocation } from '../helpers/uri'; +import { NavSource, NavTag } from '../requests/items'; -function handleNavToggle({ event, setNavExpanded }) { +type MessageAction = { + label: string; + callback: (event: React.MouseEvent) => void; +}; + +type GlobalMessage = { + message: string; + actions: Array; + isError?: boolean; +}; + +function handleNavToggle(args: { + event: Event; + setNavExpanded: Dispatch>; +}): void { + const { event, setNavExpanded } = args; event.preventDefault(); // show hide navigation for mobile version @@ -40,17 +61,23 @@ function handleNavToggle({ event, setNavExpanded }) { window.scrollTo({ top: 0 }); } -function dismissMessage(event) { +function dismissMessage(event: React.MouseEvent): void { selfoss.app.setGlobalMessage(null); 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): React.JSX.Element | null { + const { message } = props; + // Whenever message changes, dismiss it after 15 seconds. useEffect(() => { if (message !== null) { @@ -81,17 +108,22 @@ function Message({ message }) { ) : null; } -Message.propTypes = { - message: nullable(PropTypes.object).isRequired, -}; - -function NotFound() { +function NotFound(): React.JSX.Element { 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: React.ReactNode; +}; + +function CheckAuthorization(props: CheckAuthorizationProps): React.ReactNode { + const { isAllowed, returnLocation, _, children } = props; + const navigate = useNavigate(); const redirect = useEffectEvent(() => { @@ -123,31 +155,84 @@ 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: Dispatch>; + configuration: Configuration; + navSourcesExpanded: boolean; + unreadItemsCount: number; + setGlobalUnreadCount: Dispatch>; +}; + +// Work around for regex patterns not being supported +// https://github.com/remix-run/react-router/issues/8254 +function EntriesFilter(props: EntriesFilterProps) { + const { + entriesRef, + setNavExpanded, + configuration, + navSourcesExpanded, + unreadItemsCount, + setGlobalUnreadCount, + } = props; + + const params = useEntriesParams(); + + if (params === null) { + return ; + } + + return ( + + ); +} + +type PureAppProps = { + navSourcesExpanded: boolean; + setNavSourcesExpanded: Dispatch>; + offlineState: boolean; + allItemsCount: number; + allItemsOfflineCount: number; + unreadItemsCount: number; + unreadItemsOfflineCount: number; + starredItemsCount: number; + starredItemsOfflineCount: number; + globalMessage: GlobalMessage | null; + sourcesState: LoadingState; + setSourcesState: Dispatch>; + sources: Array; + setSources: 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): React.JSX.Element { + 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); @@ -377,123 +462,86 @@ 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: Configuration; }; -// 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(); +type AppState = { + /** + * tag repository + */ + tags: Array; + tagsState: LoadingState; - if (params === null) { - return ; - } + /** + * source repository + */ + sources: Array; + sourcesState: LoadingState; - return ( - - ); -} + /** + * 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; -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, + /** + * 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) { +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, }; @@ -516,7 +564,7 @@ export class App extends React.Component { this.reloadAll = this.reloadAll.bind(this); } - setTags(tags) { + setTags(tags: SetStateAction>): void { if (typeof tags === 'function') { this.setState((state) => ({ tags: tags(state.tags), @@ -526,7 +574,7 @@ export class App extends React.Component { } } - setTagsState(tagsState) { + setTagsState(tagsState: SetStateAction): void { if (typeof tagsState === 'function') { this.setState((state) => ({ tagsState: tagsState(state.tagsState), @@ -536,7 +584,7 @@ export class App extends React.Component { } } - setSources(sources) { + setSources(sources: SetStateAction>): void { if (typeof sources === 'function') { this.setState((state) => ({ sources: sources(state.sources), @@ -546,7 +594,7 @@ export class App extends React.Component { } } - setSourcesState(sourcesState) { + setSourcesState(sourcesState: SetStateAction): void { if (typeof sourcesState === 'function') { this.setState((state) => ({ sourcesState: sourcesState(state.sourcesState), @@ -556,7 +604,7 @@ export class App extends React.Component { } } - setOfflineState(offlineState) { + setOfflineState(offlineState: SetStateAction): void { if (typeof offlineState === 'function') { this.setState((state) => ({ offlineState: offlineState(state.offlineState), @@ -566,7 +614,7 @@ export class App extends React.Component { } } - setNavSourcesExpanded(navSourcesExpanded) { + setNavSourcesExpanded(navSourcesExpanded: SetStateAction): void { if (typeof navSourcesExpanded === 'function') { this.setState((state) => ({ navSourcesExpanded: navSourcesExpanded( @@ -578,7 +626,7 @@ export class App extends React.Component { } } - setUnreadItemsCount(unreadItemsCount) { + setUnreadItemsCount(unreadItemsCount: SetStateAction): void { if (typeof unreadItemsCount === 'function') { this.setState((state) => ({ unreadItemsCount: unreadItemsCount(state.unreadItemsCount), @@ -588,7 +636,9 @@ export class App extends React.Component { } } - setUnreadItemsOfflineCount(unreadItemsOfflineCount) { + setUnreadItemsOfflineCount( + unreadItemsOfflineCount: SetStateAction, + ): void { if (typeof unreadItemsOfflineCount === 'function') { this.setState((state) => ({ unreadItemsOfflineCount: unreadItemsOfflineCount( @@ -600,7 +650,7 @@ export class App extends React.Component { } } - setStarredItemsCount(starredItemsCount) { + setStarredItemsCount(starredItemsCount: SetStateAction): void { if (typeof starredItemsCount === 'function') { this.setState((state) => ({ starredItemsCount: starredItemsCount(state.starredItemsCount), @@ -610,7 +660,9 @@ export class App extends React.Component { } } - setStarredItemsOfflineCount(starredItemsOfflineCount) { + setStarredItemsOfflineCount( + starredItemsOfflineCount: SetStateAction, + ): void { if (typeof starredItemsOfflineCount === 'function') { this.setState((state) => ({ starredItemsOfflineCount: starredItemsOfflineCount( @@ -622,7 +674,7 @@ export class App extends React.Component { } } - setAllItemsCount(allItemsCount) { + setAllItemsCount(allItemsCount: SetStateAction): void { if (typeof allItemsCount === 'function') { this.setState((state) => ({ allItemsCount: allItemsCount(state.allItemsCount), @@ -632,7 +684,9 @@ export class App extends React.Component { } } - setAllItemsOfflineCount(allItemsOfflineCount) { + setAllItemsOfflineCount( + allItemsOfflineCount: SetStateAction, + ): void { if (typeof allItemsOfflineCount === 'function') { this.setState((state) => ({ allItemsOfflineCount: allItemsOfflineCount( @@ -644,7 +698,9 @@ export class App extends React.Component { } } - setGlobalMessage(globalMessage) { + setGlobalMessage( + globalMessage: SetStateAction, + ): void { if (typeof globalMessage === 'function') { this.setState((state) => ({ globalMessage: globalMessage(state.globalMessage), @@ -656,9 +712,8 @@ export class App extends React.Component { /** * Triggers fetching news from all sources. - * @return Promise */ - reloadAll() { + reloadAll(): Promise { if (!selfoss.isOnline()) { return Promise.resolve(); } @@ -674,7 +729,9 @@ export class App extends React.Component { label: this._('reload_list'), callback() { document - .querySelector('#nav-filter-unread') + .querySelector( + '#nav-filter-unread', + ) .click(); }, }, @@ -691,11 +748,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}`; @@ -728,27 +782,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(); @@ -763,7 +813,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)) { @@ -805,7 +859,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; @@ -821,7 +877,7 @@ export class App extends React.Component { } } - render() { + render(): React.JSX.Element { return ( @@ -853,15 +909,17 @@ export class App extends React.Component { } } -App.propTypes = { - configuration: PropTypes.object.isRequired, -}; - /** * 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; +}): React.JSX.Element { + const { basePath, appRef, configuration } = args; + return ( diff --git a/client/js/templates/ColorChooser.jsx b/client/js/templates/ColorChooser.tsx similarity index 86% rename from client/js/templates/ColorChooser.jsx rename to client/js/templates/ColorChooser.tsx index 07afa26ad8..7d428f452b 100644 --- a/client/js/templates/ColorChooser.jsx +++ b/client/js/templates/ColorChooser.tsx @@ -1,10 +1,10 @@ import React, { useContext, useMemo } from 'react'; -import PropTypes from 'prop-types'; -import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; +import { ClickEvent, Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { colorByBrightness } from '../helpers/color'; import { LocalizationContext } from '../helpers/i18n'; import * as icons from '../icons'; +import { NavTag } from '../requests/items'; const palette = [ '#ffccc9', @@ -71,7 +71,14 @@ const palette = [ '#340096', ]; -function ColorButton({ tag, color }) { +type ColorButtonProps = { + tag: NavTag; + color: string; +}; + +function ColorButton(props: ColorButtonProps) { + const { tag, color } = props; + const style = useMemo( () => ({ backgroundColor: color, @@ -89,18 +96,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: NavTag; + onChange: (event: ClickEvent) => void; +}; + +export default function ColorChooser(props: ColorChooserProps) { + const { tag, onChange } = props; + const style = useMemo(() => ({ backgroundColor: tag.color }), [tag.color]); const _ = useContext(LocalizationContext); @@ -134,8 +143,3 @@ export default function ColorChooser({ tag, onChange }) { ); } - -ColorChooser.propTypes = { - tag: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/EntriesPage.jsx b/client/js/templates/EntriesPage.tsx similarity index 86% rename from client/js/templates/EntriesPage.jsx rename to client/js/templates/EntriesPage.tsx index 4847315003..f841fda118 100644 --- a/client/js/templates/EntriesPage.jsx +++ b/client/js/templates/EntriesPage.tsx @@ -5,15 +5,18 @@ import React, { useMemo, useState, forwardRef, + Dispatch, + SetStateAction, + ForwardedRef, } from 'react'; -import PropTypes from 'prop-types'; -import { Link, useLocation, useParams } from 'react-router'; +import { Link, NavigateFunction } from 'react-router'; import { useOnline } from 'rooks'; import { useStateWithDeps } from 'use-state-with-deps'; -import nullable from 'prop-types-nullable'; +import selfoss from '../selfoss-base'; import Item from './Item'; import { FilterType } from '../Filter'; import * as itemsRequests from '../requests/items'; +import { EntryStatus } from '../requests/items'; import * as sourceRequests from '../requests/sources'; import { LoadingState } from '../requests/LoadingState'; import { Spinner, SpinnerBig } from './Spinner'; @@ -22,13 +25,20 @@ 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 { + Location, + useEntriesParams, + useLocation, + forceReload, + makeEntriesLinkLocation, +} from '../helpers/uri'; +import { Configuration, ConfigurationContext } from '../model/Configuration'; import { HttpError } from '../errors'; import { useNavigate } from 'react-router'; +import { FetchParams } from '../selfoss-db-online'; function reloadList({ fetchParams, @@ -39,7 +49,16 @@ function reloadList({ configuration, entryId = null, setLoadingState, -}) { +}: { + fetchParams: FetchParams; + abortController: AbortController; + navigate: NavigateFunction; + append?: boolean; + waitForSync?: boolean; + configuration: Configuration; + entryId?: number | null; + setLoadingState: Dispatch>; +}): Promise { if (abortController.signal.aborted) { return Promise.resolve(); } @@ -62,22 +81,21 @@ function reloadList({ return Promise.resolve(); } - let reloader = selfoss.dbOffline.getEntries; - - // tag, source and search filtering not supported offline (yet?) - if (fetchParams.tag || fetchParams.source || fetchParams.search) { - reloader = selfoss.dbOnline.getEntries; - } - const forceLoadOnline = selfoss.dbOffline.olderEntriesOnline || selfoss.dbOffline.shouldLoadEntriesOnline; - if ( + + // tag, source and search filtering not supported offline (yet?) + const shouldFetch = + fetchParams.tag || + fetchParams.source || + fetchParams.search || !selfoss.db.enableOffline.value || - (selfoss.isOnline() && forceLoadOnline) - ) { - reloader = selfoss.dbOnline.getEntries; - } + (selfoss.isOnline() && forceLoadOnline); + + const reloader = shouldFetch + ? selfoss.dbOnline.getEntries(fetchParams, abortController) + : selfoss.dbOffline.getEntries(fetchParams); // Clean state when not just adding items. if (!append) { @@ -88,7 +106,7 @@ function reloadList({ } setLoadingState(LoadingState.LOADING); - return reloader(fetchParams, abortController) + return reloader .then(({ entries, hasMore }) => { if (abortController.signal.aborted) { return; @@ -165,7 +183,7 @@ function reloadList({ * @param {number} selected */ function openSelectedArticle(selected) { - const link = document.querySelector( + const link = document.querySelector( `.entry[data-entry-id="${selected}"] .entry-datetime`, ); if (selfoss.config.openInBackgroundTab) { @@ -214,19 +232,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: Dispatch>; + selectedEntry: number | null; + expandedEntries: { [index: string]: boolean }; + setNavExpanded: Dispatch>; + navSourcesExpanded: boolean; + reload: () => void; + setGlobalUnreadCount: 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); @@ -240,7 +274,7 @@ export function EntriesPage({ return queryString.get('search') ?? ''; }, [location.search]); - const params = useParams(); + const params = useEntriesParams(); const currentTag = params.category?.startsWith('tag-') ? params.category.replace(/^tag-/, '') : null; @@ -267,7 +301,7 @@ export function EntriesPage({ // but do not re-fetch when the id in the URI changes later // since that happens when reading. const initialItemId = useMemo(() => { - return parseInt(params.id, 10); + return params.id; }, [params.filter, currentTag, currentSource, searchText, forceReload]); // Same for the state of navigation being expanded. // It is only passed to the API request as a part of optimization scheme @@ -344,8 +378,11 @@ export function EntriesPage({ useEffect(() => { // scroll load more const onScroll = () => { - const streamMoreButton = document.querySelector('.stream-more'); - if (!streamMoreButton) { + const streamMoreButton = + document.querySelector( + '.stream-more', + ); + if (streamMoreButton === null) { return; } @@ -519,35 +556,58 @@ 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, + selectedEntry: null, + expandedEntries: {}, + loadingState: LoadingState.INITIAL, +}; + +type Params = { + category?: string; + filter: FilterType; +}; + +type StateHolderProps = { + configuration: Configuration; + location: Location; + navigate: NavigateFunction; + params: Params; + setNavExpanded: Dispatch>; + navSourcesExpanded: boolean; + setGlobalUnreadCount: Dispatch>; + unreadItemsCount: number; +}; + +type Entry = { + id: number; + unread: boolean; + starred: boolean; + tags: string[]; + source: number; +}; + +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: null, - expandedEntries: {}, - loadingState: LoadingState.INITIAL, + selectedEntry: number | null; + expandedEntries: { + [index: number]: boolean; + }; + loadingState: LoadingState; }; -class StateHolder extends React.Component { - constructor(props) { +export class StateHolder extends React.Component< + StateHolderProps, + StateHolderState +> { + constructor(props: StateHolderProps) { super(props); this.state = initialState; @@ -586,9 +646,8 @@ 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: SetStateAction): void { if (typeof selectedEntry === 'function') { this.setState((state) => ({ selectedEntry: selectedEntry(state.selectedEntry), @@ -600,13 +659,14 @@ class StateHolder extends React.Component { /** * Get the currently selected entry. - * @return {number} */ - getSelectedEntry() { + getSelectedEntry(): number { return this.state.selectedEntry; } - setExpandedEntries(expandedEntries) { + setExpandedEntries( + expandedEntries: SetStateAction<{ [index: number]: boolean }>, + ): void { if (typeof expandedEntries === 'function') { this.setState((state) => ({ expandedEntries: expandedEntries(state.expandedEntries), @@ -616,7 +676,7 @@ class StateHolder extends React.Component { } } - setEntryExpanded(id, expand) { + setEntryExpanded(id: number, expand: SetStateAction): void { if (typeof expand === 'function') { this.setExpandedEntries((oldEntries) => ({ ...oldEntries, @@ -633,37 +693,33 @@ 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(); } @@ -678,7 +734,7 @@ class StateHolder extends React.Component { const autoMarkAsRead = selfoss.isAllowedToWrite() && this.props.configuration.autoMarkAsRead && - entry.unread == 1; + entry.unread; if (autoMarkAsRead) { this.markEntryRead(id, true); } @@ -687,13 +743,12 @@ 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) { @@ -708,7 +763,7 @@ class StateHolder extends React.Component { ); } - markEntryInView(id, unread) { + markEntryInView(id: number, unread: boolean): void { this.setEntries((entries) => entries.map((entry) => { if (entry.id === id) { @@ -723,7 +778,7 @@ class StateHolder extends React.Component { ); } - refreshEntryStatuses(entryStatuses) { + refreshEntryStatuses(entryStatuses: EntryStatus[]) { this.state.entries.forEach((entry) => { const { id } = entry; const newStatus = entryStatuses.find( @@ -736,7 +791,7 @@ class StateHolder extends React.Component { }); } - setHasMore(hasMore) { + setHasMore(hasMore: SetStateAction): void { if (typeof hasMore === 'function') { this.setState((state) => ({ hasMore: hasMore(state.hasMore), @@ -746,7 +801,7 @@ class StateHolder extends React.Component { } } - setLoadingState(loadingState) { + setLoadingState(loadingState: SetStateAction): void { if (typeof loadingState === 'function') { this.setState((state) => ({ loadingState: loadingState(state.loadingState), @@ -756,7 +811,7 @@ class StateHolder extends React.Component { } } - getActiveTag() { + getActiveTag(): string | null { const category = this.props.params?.category; if (!category) { return null; @@ -766,7 +821,7 @@ class StateHolder extends React.Component { : null; } - getActiveSource() { + getActiveSource(): number | null { const category = this.props.params?.category; if (!category) { return null; @@ -776,17 +831,17 @@ class StateHolder extends React.Component { : null; } - getActiveFilter() { + getActiveFilter(): string | null { return this.props.params?.filter; } /** * Mark all visible items as read */ - markVisibleRead() { - const ids = []; - const tagUnreadDiff = {}; - const sourceUnreadDiff = {}; + markVisibleRead(): void { + const ids: number[] = []; + const tagUnreadDiff: { [index: string]: number } = {}; + const sourceUnreadDiff: { [index: string]: number } = {}; let markedEntries = this.state.entries.map((entry) => { if (!entry.unread) { @@ -912,10 +967,8 @@ 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.'); @@ -996,10 +1049,8 @@ 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.'); @@ -1067,7 +1118,7 @@ class StateHolder extends React.Component { }); } - reload() { + reload(): void { /** * HACK: A counter that is increased every time reload action (r key) is triggered. */ @@ -1082,9 +1133,8 @@ 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}'); } @@ -1113,7 +1163,9 @@ class StateHolder extends React.Component { current = old; // attempt to load more - document.querySelector('.stream-more')?.click(); + document + .querySelector('.stream-more') + ?.click(); } else { current = this.state.entries[nextIndex].id; } @@ -1140,7 +1192,7 @@ class StateHolder extends React.Component { this.setSelectedEntry(current); } - const currentElement = document.querySelector( + const currentElement = document.querySelector( `.entry[data-entry-id="${current}"]`, ); @@ -1148,15 +1200,16 @@ class StateHolder extends React.Component { autoScroll(currentElement); // focus the title link for better keyboard navigation - currentElement.querySelector('.entry-title-link').focus(); + currentElement + .querySelector('.entry-title-link') + .focus(); } } /** * 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}'); } @@ -1165,7 +1218,7 @@ 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); @@ -1174,7 +1227,7 @@ class StateHolder extends React.Component { } } - toggleSelectedStarred() { + toggleSelectedStarred(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1182,7 +1235,7 @@ class StateHolder extends React.Component { } } - toggleSelectedRead() { + toggleSelectedRead(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1190,11 +1243,11 @@ class StateHolder extends React.Component { } } - toggleSelectedExpanded() { + toggleSelectedExpanded(): void { this.toggleEntryExpanded(this.getSelectedEntry()); } - openSelectedTarget() { + openSelectedTarget(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1202,7 +1255,7 @@ class StateHolder extends React.Component { } } - openSelectedTargetAndMarkRead() { + openSelectedTargetAndMarkRead(): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1211,7 +1264,7 @@ class StateHolder extends React.Component { } } - throw(direction) { + throw(direction: Direction): void { const selected = this.getSelectedEntry(); if (selected !== null) { @@ -1240,30 +1293,28 @@ 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: Configuration; + setNavExpanded: Dispatch>; + navSourcesExpanded: boolean; + setGlobalUnreadCount: Dispatch>; + unreadItemsCount: number; }; const StateHolderOuter = forwardRef(function StateHolderOuter( - { + props: StateHolderOuterProps, + ref: ForwardedRef, +) { + const { configuration, setNavExpanded, navSourcesExpanded, setGlobalUnreadCount, unreadItemsCount, - }, - ref, -) { + } = props; const location = useLocation(); const navigate = useNavigate(); - const params = useParams(); + const params = useEntriesParams(); return ( 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.jsx b/client/js/templates/Item.tsx similarity index 85% rename from client/js/templates/Item.jsx rename to client/js/templates/Item.tsx index 64b1b566a8..3d7c1011ac 100644 --- a/client/js/templates/Item.jsx +++ b/client/js/templates/Item.tsx @@ -1,4 +1,8 @@ import React, { + Dispatch, + MouseEvent, + RefObject, + SetStateAction, useCallback, useContext, useEffect, @@ -6,12 +10,12 @@ 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'; import classNames from 'classnames'; -import { createFocusTrap } from 'focus-trap'; +import { FocusTrap, createFocusTrap } from 'focus-trap'; +import selfoss from '../selfoss-base'; import { useAllowedToWrite } from '../helpers/authorizations'; import { useForceReload, @@ -19,14 +23,34 @@ 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'; + +type Item = { + id: number; + title: string; + strippedTitle: string; + link: string; + source: number; + tags: { [tag: string]: TagColor }; + author: string; + sourcetitle: string; + datetime: Date; + unread: boolean; + starred: boolean; + content: string; + wordCount: number; + lengthWithoutTags: number; + icon: string | null; + thumbnail: string; +}; // TODO: do the search highlights client-side -function reHighlight(text) { +function reHighlight(text: string) { return text.split(/(.+?)<\/span>/).map((n, i) => i % 2 == 0 ? ( n @@ -38,8 +62,16 @@ function reHighlight(text) { ); } -function setupLightbox({ element, setSlides, setSelectedPhotoIndex }) { - const images = element.querySelectorAll( +function setupLightbox({ + element, + setSlides, + setSelectedPhotoIndex, +}: { + element: HTMLDivElement; + setSlides: (slides: Array<{ src: string }>) => void; + setSelectedPhotoIndex: (index: number) => void; +}) { + const images = element.querySelectorAll( 'a[href$=".jpg"], a[href$=".jpeg"], a[href$=".png"], a[href$=".gif"], a[href$=".jpg:large"], a[href$=".jpeg:large"], a[href$=".png:large"], a[href$=".gif:large"]', ); @@ -137,7 +169,15 @@ function handleClick({ event, navigate, location, expanded, id, target }) { } // load images -function loadImages({ event, setImagesLoaded, contentBlock }) { +function loadImages({ + event, + setImagesLoaded, + contentBlock, +}: { + event: MouseEvent; + setImagesLoaded: Dispatch>; + contentBlock: RefObject; +}): void { event.preventDefault(); event.stopPropagation(); forceLoadImages(contentBlock.current); @@ -145,7 +185,7 @@ function loadImages({ event, setImagesLoaded, contentBlock }) { } // next item on tablet and smartphone -function openNext(event) { +function openNext(event: MouseEvent): void { event.preventDefault(); event.stopPropagation(); @@ -155,9 +195,29 @@ function openNext(event) { }); } -function ShareButton({ label, icon, item, action, showLabel = true }) { +type ShareAction = ({ + id, + url, + title, +}: { + id: number; + url: string; + title: string; +}) => void; + +type ShareButtonProps = { + label: string; + icon: string | React.JSX.Element; + item: Item; + action: ShareAction; + showLabel?: boolean; +}; + +function ShareButton(props: ShareButtonProps) { + const { label, icon, item, action, showLabel = true } = props; + const shareOnClick = useCallback( - (event) => { + (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -184,15 +244,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: TagColor; }; -function ItemTag({ tag, color }) { +function ItemTag(props: ItemTagProps) { + const { tag, color } = props; + const style = useMemo( () => ({ color: color.foreColor, backgroundColor: color.backColor }), [color], @@ -222,47 +281,48 @@ 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. - * @param {Date} currentTime - * @param {Date} datetime * @return {?String} relative time reference */ -function datetimeRelative(currentTime, datetime) { - const ageInseconds = (currentTime - datetime) / 1000; +function datetimeRelative(currentTime: Date, datetime: Date): string | null { + const ageInseconds = (currentTime.getTime() - datetime.getTime()) / 1000; const ageInMinutes = ageInseconds / 60; const ageInHours = ageInMinutes / 60; const ageInDays = ageInHours / 24; if (ageInHours < 1) { - return selfoss.app._('minutes', [Math.round(ageInMinutes)]); + return selfoss.app._('minutes', { + '0': Math.round(ageInMinutes).toString(), + }); } else if (ageInDays < 1) { - return selfoss.app._('hours', [Math.round(ageInHours)]); + return selfoss.app._('hours', { + '0': Math.round(ageInHours).toString(), + }); } else { return null; } } -export default function Item({ - currentTime, - item, - selected, - expanded, - setNavExpanded, -}) { +type ItemProps = { + currentTime: Date; + item: Item; + 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 configuration = useContext(ConfigurationContext); const shouldAutoLoadImages = !selfoss.isMobile() || configuration.loadImagesOnMobile; const [imagesLoaded, setImagesLoaded] = useState(shouldAutoLoadImages); - const contentBlock = useRef(null); + const contentBlock = useRef(null); const location = useLocation(); const navigate = useNavigate(); @@ -277,13 +337,13 @@ export default function Item({ const [slides, setSlides] = useState([]); const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null); - const fullScreenTrap = useRef(null); + const fullScreenTrap = useRef(null); // This should match scenarios where fullScreenTrap is set. const usingFocusTrap = expanded && selfoss.isSmartphone(); useEffect(() => { // Handle entry becoming/ceasing to be expanded. - const parent = document.querySelector( + const parent = document.querySelector( `.entry[data-entry-id="${item.id}"]`, ); if (expanded) { @@ -357,7 +417,9 @@ export default function Item({ contentBlock.current.getBoundingClientRect() .height > document.body.clientHeight ) { - contentBlock.current.parentNode.classList.add( + const entryContent = contentBlock.current + .parentNode as HTMLDivElement; + entryContent.classList.add( 'entry-content-nocolumns', ); } @@ -380,8 +442,8 @@ export default function Item({ // Handle autoHideReadOnMobile setting. if (selfoss.isSmartphone() && !expanded && previouslyExpanded) { const autoHideReadOnMobile = - configuration.autoHideReadOnMobile && item.unread == 1; - if (autoHideReadOnMobile && item.unread != 1) { + configuration.autoHideReadOnMobile && item.unread; + if (autoHideReadOnMobile && !item.unread) { selfoss.entriesPage.setEntries((entries) => entries.filter(({ id }) => id !== item.id), ); @@ -390,7 +452,7 @@ export default function Item({ }, [configuration, expanded, item.id, item.unread, previouslyExpanded]); const entryOnClick = useCallback( - (event) => + (event: MouseEvent) => handleClick({ event, navigate, @@ -403,7 +465,7 @@ export default function Item({ ); const titleOnClick = useCallback( - (event) => + (event: MouseEvent) => handleClick({ event, navigate, @@ -416,30 +478,31 @@ export default function Item({ ); const starOnClick = useCallback( - (event) => { + (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - selfoss.entriesPage.markEntryStarred(item.id, item.starred != 1); + selfoss.entriesPage.markEntryStarred(item.id, !item.starred); }, [item], ); const markReadOnClick = useCallback( - (event) => { + (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - selfoss.entriesPage.markEntryRead(item.id, item.unread == 1); + selfoss.entriesPage.markEntryRead(item.id, item.unread); }, [item], ); const loadImagesOnClick = useCallback( - (event) => loadImages({ event, setImagesLoaded, contentBlock }), + (event: MouseEvent) => + loadImages({ event, setImagesLoaded, contentBlock }), [], ); const closeOnClick = useCallback( - (event) => + (event: MouseEvent) => closeFullScreen({ event, navigate, location, entryId: item.id }), [navigate, location, item.id], ); @@ -472,7 +535,7 @@ export default function Item({ data-entry-url={item.link} className={classNames({ entry: true, - unread: item.unread == 1, + unread: item.unread, expanded, selected, })} @@ -484,7 +547,7 @@ export default function Item({ 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 && @@ -162,7 +176,3 @@ export default function OpmlImport({ setTitle }) { ); } - -OpmlImport.propTypes = { - setTitle: PropTypes.func.isRequired, -}; diff --git a/client/js/templates/SearchList.jsx b/client/js/templates/SearchList.tsx similarity index 89% rename from client/js/templates/SearchList.jsx rename to client/js/templates/SearchList.tsx index 31d86f6a50..b7b34ada68 100644 --- a/client/js/templates/SearchList.jsx +++ b/client/js/templates/SearchList.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useMemo } from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { useLocation, useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useLocation } from '../helpers/uri'; import { makeEntriesLink } from '../helpers/uri'; import * as icons from '../icons'; @@ -49,7 +49,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 +76,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.jsx b/client/js/templates/Source.tsx similarity index 84% rename from client/js/templates/Source.jsx rename to client/js/templates/Source.tsx index ca66ecbefe..7c99e6a3fd 100644 --- a/client/js/templates/Source.jsx +++ b/client/js/templates/Source.tsx @@ -1,14 +1,20 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { + Dispatch, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { useRef } from 'react'; 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'; +import selfoss from '../selfoss-base'; import SourceParam from './SourceParam'; import { Spinner } from './Spinner'; import * as sourceRequests from '../requests/sources'; @@ -18,7 +24,14 @@ import { LocalizationContext } from '../helpers/i18n'; const FAST_DURATION_MS = 200; // cancel source editing -function handleCancel({ source, sourceElem, setSources, setEditedSource }) { +function handleCancel(args: { + event?: any; + source: any; + sourceElem: any; + setSources: any; + setEditedSource: any; +}) { + const { source, sourceElem, setSources, setEditedSource } = args; const id = source.id; if (id.toString().startsWith('new-')) { @@ -38,15 +51,24 @@ function handleCancel({ source, sourceElem, setSources, setEditedSource }) { } // save source -function handleSave({ - event, - setSources, - source, - setEditedSource, - setSourceActionLoading, - setJustSavedTimeout, - setSourceErrors, +function handleSave(args: { + event: any; + setSources: any; + source: any; + setEditedSource: any; + setSourceActionLoading: any; + setJustSavedTimeout: any; + setSourceErrors: any; }) { + const { + event, + setSources, + source, + setEditedSource, + setSourceActionLoading, + setJustSavedTimeout, + setSourceErrors, + } = args; event.preventDefault(); // remove old errors @@ -129,13 +151,15 @@ function handleSave({ } // delete source -function handleDelete({ - source, - sourceElem, - setSources, - setSourceBeingDeleted, - setDirty, +function handleDelete(args: { + source: any; + sourceElem: any; + setSources: any; + setSourceBeingDeleted: any; + setDirty: any; }) { + const { source, sourceElem, setSources, setSourceBeingDeleted, setDirty } = + args; const answer = confirm(selfoss.app._('source_warn')); if (answer == false) { return; @@ -178,7 +202,8 @@ function handleDelete({ } // start editing -function handleEdit({ event, source, setEditedSource }) { +function handleEdit(args: { event: any; source: any; setEditedSource: any }) { + const { event, source, setEditedSource } = args; event.preventDefault(); const { id, title, tags, filter, spout, params } = source; @@ -194,13 +219,20 @@ function handleEdit({ event, source, setEditedSource }) { } // select new source spout type -function handleSpoutChange({ - event, - setSpouts, - updateEditedSource, - setSourceParamsLoading, - setSourceParamsError, +function handleSpoutChange(args: { + event: any; + setSpouts: Dispatch>; + updateEditedSource: any; + setSourceParamsLoading: any; + setSourceParamsError: any; }) { + const { + event, + setSpouts, + updateEditedSource, + setSourceParamsLoading, + setSourceParamsError, + } = args; const spoutClass = event.target.value; updateEditedSource({ spout: spoutClass }); @@ -240,7 +272,7 @@ function handleSpoutChange({ // Taken from https://stackoverflow.com/a/15289883/160386 const MS_PER_DAY = 1000 * 60 * 60 * 24; -function daysAgo(date) { +function daysAgo(date: Date): number { // Get number of days between now and when the last entry was seen // Note: The time of the two dates is set to midnight // to get the difference of the two dates in calendar days @@ -253,26 +285,80 @@ 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, -}) { +export type SpoutParam = { + title: string; + default: string; +} & ( + | { + type: 'text' | 'url' | 'password' | 'checkbox'; + } + | { + type: 'select'; + values: { [s: string]: string }; + } +); + +export 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: Source; + sourceElem: object; + sourceError?: string; + setSources: Dispatch>>; + spouts: { [className: string]: Spout }; + setSpouts: Dispatch>; + setEditedSource: Dispatch>; + sourceActionLoading: boolean; + setSourceActionLoading: Dispatch>; + sourceParamsLoading: boolean; + setSourceParamsLoading: Dispatch>; + sourceParamsError: string | null; + setSourceParamsError: Dispatch>; + setJustSavedTimeout: Dispatch>; + sourceErrors: { [index: string]: string }; + setSourceErrors: Dispatch>; + dirty: boolean; + setDirty: 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 +621,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: Source; + setSources: Dispatch>>; + spouts: { [className: string]: Spout }; + setSpouts: Dispatch>; + dirty: boolean; + setDirtySources: 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, @@ -612,7 +682,7 @@ export default function Source({ const sourceElem = useRef(null); const extraMenuOnSelection = useCallback( - ({ value }) => { + ({ value }: { value?: string }) => { if (value === 'delete') { handleDelete({ source, @@ -708,7 +778,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 +811,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.jsx b/client/js/templates/SourceParam.tsx similarity index 80% rename from client/js/templates/SourceParam.jsx rename to client/js/templates/SourceParam.tsx index 8a529f432f..0dbe383ab9 100644 --- a/client/js/templates/SourceParam.jsx +++ b/client/js/templates/SourceParam.tsx @@ -1,16 +1,35 @@ -import React, { useCallback, useContext } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Dispatch, + SetStateAction, + useCallback, + useContext, +} from 'react'; import { LocalizationContext } from '../helpers/i18n'; +import { Source, SpoutParam } from './Source'; + +type SourceParamProps = { + spoutParamName: string; + spoutParam: SpoutParam; + params: { [index: string]: string }; + sourceErrors: { [index: string]: string }; + sourceId: number; + setEditedSource: Dispatch>; + setDirty: Dispatch>; +}; + +export default function SourceParam( + props: SourceParamProps, +): React.JSX.Element { + const { + spoutParamName, + spoutParam, + params = {}, + sourceErrors, + sourceId, + setEditedSource, + setDirty, + } = props; -export default function SourceParam({ - spoutParamName, - spoutParam, - params = {}, - sourceErrors, - sourceId, - setEditedSource, - setDirty, -}) { const updateSourceParam = useCallback( (event) => { setDirty(true); @@ -80,7 +99,7 @@ export default function SourceParam({