diff --git a/client/js/helpers/hooks.ts b/client/js/helpers/hooks.ts index 01b54f52d..6141634d2 100644 --- a/client/js/helpers/hooks.ts +++ b/client/js/helpers/hooks.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import { useMediaMatch } from 'rooks'; +import { useLocation } from '../templates/App'; import { ValueListenable } from './ValueListenable'; /** diff --git a/client/js/helpers/uri.ts b/client/js/helpers/uri.ts index f7ff6e7ff..34a2d2878 100644 --- a/client/js/helpers/uri.ts +++ b/client/js/helpers/uri.ts @@ -1,5 +1,5 @@ import { generatePath } from 'react-router-dom'; -import { Location } from 'history'; +import { Location } from '../templates/App'; import { FilterType } from '../Filter'; /** diff --git a/client/js/requests/items.ts b/client/js/requests/items.ts index ae642cb7c..a5f3bcd33 100644 --- a/client/js/requests/items.ts +++ b/client/js/requests/items.ts @@ -132,6 +132,28 @@ type StatusUpdate = { type SyncParams = { updatedStatuses: Array; + since?: Date; + itemsNotBefore?: Date; +}; + +export type EntryStatus = { + id: number; + unread: boolean; + starred: boolean; +}; + +export type NavTag = { tag: string; unread: number }; + +export type NavSource = { id: number; unread: number }; + +export type Stats = { all: number; unread: number; starred: number }; + +type SyncResponse = { + entries: Array; + stats?: Stats; + tags?: NavTag[]; + sources?: NavSource[]; + itemUpdates?: EntryStatus[]; }; /** @@ -140,7 +162,7 @@ type SyncParams = { export function sync( updatedStatuses: Array, syncParams: SyncParams, -): { controller: AbortController; promise: Promise } { +): { controller: AbortController; promise: Promise } { const params = { ...syncParams, updatedStatuses: syncParams.updatedStatuses diff --git a/client/js/selfoss-base.ts b/client/js/selfoss-base.ts index 71ae4db76..fb810d7a5 100644 --- a/client/js/selfoss-base.ts +++ b/client/js/selfoss-base.ts @@ -6,7 +6,7 @@ import { ValueListenable } from './helpers/ValueListenable'; import { HttpError, TimeoutError } from './errors'; import { Configuration } from './model/Configuration'; import { LoadingState } from './requests/LoadingState'; -import { App, createApp } from './templates/App'; +import { App, History, createApp } from './templates/App'; import dbOnline from './selfoss-db-online'; import dbOffline from './selfoss-db-offline'; import db from './selfoss-db'; @@ -39,6 +39,7 @@ class selfoss { public static db = db; public static dbOnline = dbOnline; public static dbOffline = dbOffline; + static history: History; /** * initialize application diff --git a/client/js/templates/App.tsx b/client/js/templates/App.tsx index 384eb6665..4413a78db 100644 --- a/client/js/templates/App.tsx +++ b/client/js/templates/App.tsx @@ -1,3 +1,7 @@ +import { + History as HistoryGeneric, + Location as LocationGeneric, +} from 'history'; import React, { useCallback, useContext, useEffect, useState } from 'react'; import { BrowserRouter as Router, @@ -5,8 +9,9 @@ import { Route, Link, Redirect, - useHistory, - useLocation, + useHistory as useHistoryGeneric, + useLocation as useLocationGeneric, + useParams as useParamsGeneric, } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Collapse } from '@kunukn/react-collapse'; @@ -29,6 +34,26 @@ import { Configuration, ConfigurationContext } from '../model/Configuration'; import { LoadingState } from '../requests/LoadingState'; import * as sourceRequests from '../requests/sources'; import locales from '../locales'; +import { NavSource, NavTag } from '../requests/items'; +import { FilterType } from '../Filter'; + +export type LocationState = { + error?: string; + returnLocation?: string; + forceReload?: number; +}; +export type Params = { + filter?: FilterType; + category?: string; + id?: string; +}; + +export type History = HistoryGeneric; +export type Location = LocationGeneric; + +export const useHistory = useHistoryGeneric; +export const useParams = useParamsGeneric; +export const useLocation = useLocationGeneric; type MessageAction = { label: string; @@ -391,13 +416,13 @@ type AppState = { /** * tag repository */ - tags: Array; + tags: Array; tagsState: LoadingState; /** * source repository */ - sources: Array; + sources: Array; sourcesState: LoadingState; /** diff --git a/client/js/templates/EntriesPage.tsx b/client/js/templates/EntriesPage.tsx index e4cd1e4d0..c360f152e 100644 --- a/client/js/templates/EntriesPage.tsx +++ b/client/js/templates/EntriesPage.tsx @@ -5,13 +5,15 @@ import React, { useMemo, useState, } from 'react'; -import { Link, useLocation, useParams } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { useOnline } from 'rooks'; import { useStateWithDeps } from 'use-state-with-deps'; +import { Location, useLocation, useParams } from './App'; 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'; @@ -24,7 +26,7 @@ import { autoScroll, Direction } from '../helpers/navigation'; import { LocalizationContext } from '../helpers/i18n'; import { useShouldReload } from '../helpers/hooks'; import { forceReload, makeEntriesLinkLocation } from '../helpers/uri'; -import { ConfigurationContext } from '../model/Configuration'; +import { Configuration, ConfigurationContext } from '../model/Configuration'; import { HttpError } from '../errors'; function reloadList({ @@ -535,18 +537,33 @@ const initialState = { loadingState: LoadingState.INITIAL, }; +type Match = { + params: { + category?: string; + filter: FilterType; + }; +}; + type StateHolderProps = { - configuration: object; - location: object; - match: object; + configuration: Configuration; + location: Location; + match: Match; setNavExpanded: React.Dispatch>; navSourcesExpanded: boolean; setGlobalUnreadCount: React.Dispatch>; unreadItemsCount: number; }; +type Entry = { + id: number; + unread: boolean; + starred: boolean; + tags: string[]; + source: number; +}; + type StateHolderState = { - entries: Array; + entries: Array; hasMore: boolean; /** * Currently selected entry. @@ -691,7 +708,7 @@ export default class StateHolder extends React.Component< const autoMarkAsRead = selfoss.isAllowedToWrite() && this.props.configuration.autoMarkAsRead && - entry.unread == 1; + entry.unread; if (autoMarkAsRead) { this.markEntryRead(id, true); } @@ -735,7 +752,7 @@ export default class StateHolder extends React.Component< ); } - refreshEntryStatuses(entryStatuses) { + refreshEntryStatuses(entryStatuses: EntryStatus[]) { this.state.entries.forEach((entry) => { const { id } = entry; const newStatus = entryStatuses.find( @@ -799,9 +816,9 @@ export default class StateHolder extends React.Component< * Mark all visible items as read */ markVisibleRead(): void { - const ids = []; - const tagUnreadDiff = {}; - const sourceUnreadDiff = {}; + const ids: number[] = []; + const tagUnreadDiff: { [index: string]: number } = {}; + const sourceUnreadDiff: { [index: string]: number } = {}; let markedEntries = this.state.entries.map((entry) => { if (!entry.unread) { diff --git a/client/js/templates/HashPassword.tsx b/client/js/templates/HashPassword.tsx index 8e420a61e..1545aa25a 100644 --- a/client/js/templates/HashPassword.tsx +++ b/client/js/templates/HashPassword.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory } from './App'; import { useInput } from 'rooks'; import { LoadingState } from '../requests/LoadingState'; import { HttpError } from '../errors'; diff --git a/client/js/templates/Item.tsx b/client/js/templates/Item.tsx index 457b71207..538bf2f52 100644 --- a/client/js/templates/Item.tsx +++ b/client/js/templates/Item.tsx @@ -6,11 +6,12 @@ import React, { useRef, useState, } from 'react'; -import { Link, useHistory, useLocation } from 'react-router-dom'; +import { Link } from 'react-router-dom'; 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 { useHistory, useLocation } from './App'; import selfoss from '../selfoss-base'; import { useAllowedToWrite } from '../helpers/authorizations'; import { @@ -25,8 +26,27 @@ import { ConfigurationContext } from '../model/Configuration'; import { useSharers } from '../sharers'; import Lightbox from 'yet-another-react-lightbox'; +type Item = { + id: number; + title: string; + strippedTitle: string; + link: string; + source: number; + tags: { [tag: string]: string }; + 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 @@ -160,7 +180,7 @@ type ShareAction = ({ id: string, url: string, title: string }) => void; type ShareButtonProps = { label: string; icon: string | HTMLElement; - item: object; + item: Item; action: ShareAction; showLabel?: boolean; }; @@ -236,20 +256,22 @@ function ItemTag(props: ItemTagProps) { /** * 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; } @@ -257,7 +279,7 @@ function datetimeRelative(currentTime, datetime) { type ItemProps = { currentTime: Date; - item: object; + item: Item; selected: boolean; expanded: boolean; setNavExpanded: React.Dispatch>; @@ -268,7 +290,9 @@ export default function Item(props: ItemProps) { const { title, author, sourcetitle } = item; - const [fullScreenTrap, setFullScreenTrap] = useState(null); + const [fullScreenTrap, setFullScreenTrap] = useState( + null, + ); const [imagesLoaded, setImagesLoaded] = useState(false); const contentBlock = useRef(null); @@ -392,8 +416,8 @@ export default function Item(props: ItemProps) { // 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), ); @@ -431,7 +455,7 @@ export default function Item(props: ItemProps) { (event) => { event.preventDefault(); event.stopPropagation(); - selfoss.entriesPage.markEntryStarred(item.id, item.starred != 1); + selfoss.entriesPage.markEntryStarred(item.id, !item.starred); }, [item], ); @@ -440,7 +464,7 @@ export default function Item(props: ItemProps) { (event) => { event.preventDefault(); event.stopPropagation(); - selfoss.entriesPage.markEntryRead(item.id, item.unread == 1); + selfoss.entriesPage.markEntryRead(item.id, item.unread); }, [item], ); @@ -486,7 +510,7 @@ export default function Item(props: ItemProps) { data-entry-url={item.link} className={classNames({ entry: true, - unread: item.unread == 1, + unread: item.unread, expanded, selected, })} @@ -674,18 +698,14 @@ export default function Item(props: ItemProps) { accessKey="a" className={classNames({ 'entry-starr': true, - active: item.starred == 1, + active: item.starred, })} onClick={starOnClick} > {' '} - {item.starred == 1 ? _('unstar') : _('star')} + {item.starred ? _('unstar') : _('star')} )} @@ -695,18 +715,18 @@ export default function Item(props: ItemProps) { accessKey="u" className={classNames({ 'entry-unread': true, - active: item.unread == 1, + active: item.unread, })} onClick={markReadOnClick} > {' '} - {item.unread == 1 ? _('mark') : _('unmark')} + {item.unread ? _('mark') : _('unmark')} )} diff --git a/client/js/templates/LoginForm.tsx b/client/js/templates/LoginForm.tsx index 4f4eccf57..723f673a9 100644 --- a/client/js/templates/LoginForm.tsx +++ b/client/js/templates/LoginForm.tsx @@ -1,10 +1,9 @@ import React, { useCallback, useContext, useState } from 'react'; import classNames from 'classnames'; -import { History } from 'history'; +import { History, useHistory, useLocation } from './App'; import selfoss from '../selfoss-base'; import { SpinnerBig } from './Spinner'; import { Configuration } from '../model/Configuration'; -import { useHistory, useLocation } from 'react-router-dom'; import { HttpError, LoginError } from '../errors'; import { LocalizationContext } from '../helpers/i18n'; import { ConfigurationContext } from '../model/Configuration'; diff --git a/client/js/templates/NavSearch.tsx b/client/js/templates/NavSearch.tsx index 4c02d5f57..40d99546e 100644 --- a/client/js/templates/NavSearch.tsx +++ b/client/js/templates/NavSearch.tsx @@ -5,9 +5,9 @@ import React, { useRef, useState, } from 'react'; -import { useLocation, useHistory } from 'react-router-dom'; import classNames from 'classnames'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useHistory, useLocation } from './App'; import selfoss from '../selfoss-base'; import { makeEntriesLink } from '../helpers/uri'; import * as icons from '../icons'; diff --git a/client/js/templates/OpmlImport.tsx b/client/js/templates/OpmlImport.tsx index e23303bfb..8cad25143 100644 --- a/client/js/templates/OpmlImport.tsx +++ b/client/js/templates/OpmlImport.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useOnline } from 'rooks'; -import { Link, useHistory } from 'react-router-dom'; +import { Link } from 'react-router-dom'; +import { useHistory } from './App'; import { LoadingState } from '../requests/LoadingState'; import { HttpError, UnexpectedStateError } from '../errors'; import { importOpml, OpmlImportData } from '../requests/common'; diff --git a/client/js/templates/SearchList.tsx b/client/js/templates/SearchList.tsx index c73740b0e..0b24c7233 100644 --- a/client/js/templates/SearchList.tsx +++ b/client/js/templates/SearchList.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import classNames from 'classnames'; -import { useLocation, useHistory } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useHistory, useLocation } from './App'; import { makeEntriesLink } from '../helpers/uri'; import * as icons from '../icons'; diff --git a/client/js/templates/Source.tsx b/client/js/templates/Source.tsx index 899459cb1..2b29054e8 100644 --- a/client/js/templates/Source.tsx +++ b/client/js/templates/Source.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useRef } from 'react'; import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu'; -import { useHistory, useLocation } from 'react-router-dom'; import { fadeOut } from '@siteparts/show-hide-effects'; +import { useHistory, useLocation } from './App'; import { makeEntriesLinkLocation } from '../helpers/uri'; import { unescape } from 'html-escaper'; import classNames from 'classnames'; @@ -265,7 +265,7 @@ function handleSpoutChange(args: { // 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 diff --git a/client/js/templates/SourcesPage.tsx b/client/js/templates/SourcesPage.tsx index a93eed805..78ff41f2b 100644 --- a/client/js/templates/SourcesPage.tsx +++ b/client/js/templates/SourcesPage.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useMemo } from 'react'; import { Prompt } from 'react-router'; -import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom'; +import { Link, useRouteMatch } from 'react-router-dom'; +import { useHistory, useLocation } from './App'; import selfoss from '../selfoss-base'; import Source from './Source'; import { SpinnerBig } from './Spinner'; diff --git a/client/package-lock.json b/client/package-lock.json index 568052a63..ce64634cb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -38,7 +38,10 @@ "@parcel/transformer-sass": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0", "@types/history": "^4.7.11", + "@types/html-escaper": "^3.0.2", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.2.17", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "autoprefixer": "^10.4.0", @@ -2499,6 +2502,27 @@ "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", "dev": true }, + "node_modules/@types/html-escaper": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/html-escaper/-/html-escaper-3.0.2.tgz", + "integrity": "sha512-A8vk09eyYzk8J/lFO4OUMKCmRN0rRzfZf4n3Olwapgox/PtTiU8zPYlL1UEkJ/WeHvV6v9Xnj3o/705PKz9r4Q==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==", + "dev": true + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -2515,6 +2539,27 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz", diff --git a/client/package.json b/client/package.json index 7fa797e3d..810a57acc 100644 --- a/client/package.json +++ b/client/package.json @@ -33,7 +33,10 @@ "@parcel/transformer-sass": "^2.0.0", "@parcel/transformer-webmanifest": "^2.0.0", "@types/history": "^4.7.11", + "@types/html-escaper": "^3.0.2", + "@types/lodash-es": "^4.17.12", "@types/react": "^18.2.17", + "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "autoprefixer": "^10.4.0",