diff --git a/core/client.ts b/core/client.ts index 61ffeb16..0d40307f 100644 --- a/core/client.ts +++ b/core/client.ts @@ -51,6 +51,7 @@ onEnvironment(({ logStoreCreator }) => { export function getClient(): CrossTabClient { let logux = client.get() if (!logux) { + /* c8 ignore next 2 */ throw new Error('No Slow Reader client') } return logux diff --git a/core/current-page.ts b/core/current-page.ts new file mode 100644 index 00000000..2f8a582b --- /dev/null +++ b/core/current-page.ts @@ -0,0 +1,75 @@ +import { computed, type ReadableAtom, type WritableStore } from 'nanostores' + +import { getEnvironment } from './environment.ts' +import { type Page, pages } from './pages/index.ts' +import { type Route, router } from './router.ts' + +function isStore(store: unknown): store is WritableStore { + return typeof store === 'object' && store !== null && 'listen' in store +} + +function eachParam( + page: Page, + route: SomeRoute, + iterator: ( + store: WritableStore, + name: Param, + value: SomeRoute['params'][Param] + ) => void +): void { + let params = route.params as SomeRoute['params'] + for (let i in params) { + let name = i as keyof SomeRoute['params'] + let value = params[name] + let store = page[name] + if (isStore(store)) { + iterator(store, name, value) + } + } +} + +function changeRouteParam( + route: Route, + change: Partial +): void { + getEnvironment().openRoute({ + ...route, + params: { + ...route.params, + ...change + } + } as Route) +} + +let prevPage: Page | undefined +let unbinds: (() => void)[] = [] + +export const currentPage: ReadableAtom = computed(router, route => { + let page = pages[route.route]() as Page + if (page !== prevPage) { + if (prevPage) { + for (let unbind of unbinds) unbind() + prevPage.destroy() + } + prevPage = page + + eachParam(page, route, (store, param) => { + unbinds.push( + store.listen(newValue => { + let currentRoute = router.get() + if (currentRoute.route === page.route) { + changeRouteParam(currentRoute, { [param]: newValue }) + } + }) + ) + }) + } + + eachParam(page, route, (store, param, value) => { + if (store.get() !== value) { + store.set(value) + } + }) + + return page +}) diff --git a/core/download.ts b/core/download.ts index 94cf673d..0e77204b 100644 --- a/core/download.ts +++ b/core/download.ts @@ -1,6 +1,8 @@ import { warning } from './devtools.ts' import { request } from './request.ts' +let cache = new Map() + export interface TextResponse { readonly contentType: string readonly headers: Headers @@ -14,7 +16,7 @@ export interface TextResponse { } export interface DownloadTask { - abortAll(): void + destroy(): void request: typeof request text(...args: Parameters): Promise } @@ -107,18 +109,32 @@ export function createTextResponse( } } -export function createDownloadTask(): DownloadTask { +export function createDownloadTask( + taskOpts: { cache?: 'read' | 'write' | false } = {} +): DownloadTask { let controller = new AbortController() + let cached: string[] = [] return { - abortAll() { + destroy() { + for (let url of cached) cache.delete(url) controller.abort() }, - request(url, opts = {}) { - return request(url, { + async request(url, opts = {}) { + if (taskOpts.cache) { + let fromCache = cache.get(url) + if (fromCache) return fromCache.clone() + } + + let response = await request(url, { redirect: 'follow', signal: controller.signal, ...opts }) + if (taskOpts.cache === 'write') { + cached.push(url) + cache.set(url, response.clone()) + } + return response }, async text(url, opts) { let response = await this.request(url, opts) diff --git a/core/environment.ts b/core/environment.ts index b754e9c2..2aa34e3c 100644 --- a/core/environment.ts +++ b/core/environment.ts @@ -82,6 +82,7 @@ function runEnvListener(listener: EnvironmentListener): void { export function onEnvironment(cb: EnvironmentListener): void { if (currentEnvironment) { + /* c8 ignore next 2 */ runEnvListener(cb) } listeners.push(cb) @@ -113,6 +114,7 @@ export function setupEnvironment( export function getEnvironment(): Environment { if (!currentEnvironment) { + /* c8 ignore next 2 */ throw new Error('No Slow Reader environment') } return currentEnvironment @@ -126,8 +128,17 @@ export function setIsMobile(isSmallScreen: boolean): void { const testRouter = atom() -export function setBaseTestRoute(route: BaseRoute | undefined): void { - testRouter.set(route) +export function addHashToBaseRoute( + route: BaseRoute | Omit | undefined +): BaseRoute | undefined { + if (!route) return undefined + return { hash: '', ...route } as BaseRoute +} + +export function setBaseTestRoute( + route: BaseRoute | Omit | undefined +): void { + testRouter.set(addHashToBaseRoute(route)) } export function getTestEnvironment(): EnvironmentAndStore { @@ -137,7 +148,9 @@ export function getTestEnvironment(): EnvironmentAndStore { locale: atom('en'), logStoreCreator: () => new MemoryStore(), networkType: () => ({ saveData: undefined, type: undefined }), - openRoute: setBaseTestRoute, + openRoute: route => { + setBaseTestRoute({ ...route, hash: '' }) + }, persistentEvents: { addEventListener() {}, removeEventListener() {} }, persistentStore: {}, restartApp: () => {}, diff --git a/core/fast.ts b/core/fast.ts index 320e91dc..f62cca20 100644 --- a/core/fast.ts +++ b/core/fast.ts @@ -228,6 +228,7 @@ onEnvironment(({ openRoute }) => { if (notSynced(router.get())) { openRoute({ params: { category, since }, + popups: [], route: 'fast' }) } diff --git a/core/i18n.ts b/core/i18n.ts index 77e6b6c9..0d936343 100644 --- a/core/i18n.ts +++ b/core/i18n.ts @@ -7,6 +7,8 @@ let $locale = atom('en') let loader: TranslationLoader +/* c8 ignore start */ +// TODO: Until we will have real translations export const i18n = createI18n($locale, { get(...args) { return loader(...args) @@ -19,3 +21,4 @@ onEnvironment(({ locale, translationLoader }) => { $locale.set(value) }) }) +/* c8 ignore stop */ diff --git a/core/index.ts b/core/index.ts index c4a0e80f..7d11a57f 100644 --- a/core/index.ts +++ b/core/index.ts @@ -2,6 +2,7 @@ export * from './busy.ts' export * from './category.ts' export * from './client.ts' export * from './comfort.ts' +export * from './current-page.ts' export * from './devtools.ts' export * from './download.ts' export * from './environment.ts' @@ -13,10 +14,14 @@ export * from './filter.ts' export * from './html.ts' export * from './i18n.ts' export * from './import.ts' +export { waitLoading } from './lib/stores.ts' export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' export * from './not-found.ts' +export * from './opened-popups.ts' +export * from './pages/index.ts' +export * from './popups/index.ts' export * from './post.ts' export * from './posts-list.ts' export * from './preview.ts' diff --git a/core/lib/stores.ts b/core/lib/stores.ts index 2f2ed578..9cc5a378 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -1,3 +1,5 @@ +import type { SyncMapValues } from '@logux/actions' +import type { LoadedSyncMap, SyncMapStore } from '@logux/client' import type { MapStore, ReadableAtom, @@ -67,3 +69,31 @@ export function computeFrom( } }) } + +export function waitLoading( + store: ReadableAtom +): Promise { + return new Promise(resolve => { + let unbind = store.listen(state => { + if (state === false) { + unbind() + resolve() + } + }) + }) +} + +export async function waitSyncLoading( + store: SyncMapStore +): Promise>> { + let value = store.get() + if (value.isLoading) { + let unbind = store.listen(() => {}) + try { + await store.loading + } finally { + unbind() + } + } + return store as LoadedSyncMap> +} diff --git a/core/loader/index.ts b/core/loader/index.ts index 5947f31b..f0572732 100644 --- a/core/loader/index.ts +++ b/core/loader/index.ts @@ -16,6 +16,36 @@ export const loaders = { atom, jsonFeed, rss +} satisfies { + [Name in string]: Loader } export type LoaderName = keyof typeof loaders + +export interface FeedLoader { + loader: Loader + name: LoaderName + title: string + url: string +} + +export function getLoaderForText(response: TextResponse): false | FeedLoader { + if (response.ok) { + let names = Object.keys(loaders) as LoaderName[] + let parsed = new URL(response.url) + for (let name of names) { + if (loaders[name].isMineUrl(parsed) !== false) { + let title = loaders[name].isMineText(response) + if (title !== false) { + return { + loader: loaders[name], + name, + title: title.trim(), + url: response.url + } + } + } + } + } + return false +} diff --git a/core/not-found.ts b/core/not-found.ts index 9a6ccb36..2d1ae0b5 100644 --- a/core/not-found.ts +++ b/core/not-found.ts @@ -1,4 +1,4 @@ -import { LoguxUndoError } from '@logux/client' +import type { LoguxUndoError } from '@logux/client' import { atom } from 'nanostores' import { onEnvironment } from './environment.ts' @@ -6,12 +6,29 @@ import { router } from './router.ts' export const notFound = atom(false) +export class NotFoundError extends Error { + constructor() { + super('Not found') + this.name = 'NotFoundError' + Error.captureStackTrace(this, NotFoundError) + } +} + +export function isNotFoundError( + error: unknown +): error is LoguxUndoError | NotFoundError { + if (error instanceof Error) { + return ( + error.name === 'NotFoundError' || + (error.name === 'LoguxUndoError' && error.message.includes('notFound')) + ) + } + return false +} + onEnvironment(({ errorEvents }) => { errorEvents.addEventListener('unhandledrejection', event => { - if ( - event.reason instanceof LoguxUndoError && - event.reason.message.includes('notFound') - ) { + if (isNotFoundError(event.reason)) { notFound.set(true) } diff --git a/core/opened-popups.ts b/core/opened-popups.ts new file mode 100644 index 00000000..9273d0cc --- /dev/null +++ b/core/opened-popups.ts @@ -0,0 +1,29 @@ +import { computed, type ReadableAtom } from 'nanostores' + +import { type Popup, popups } from './popups/index.ts' +import { router } from './router.ts' + +let prevPopups: Popup[] = [] + +export const openedPopups: ReadableAtom = computed(router, route => { + let lastIndex = 0 + let nextPopups = route.popups.map((popup, index) => { + lastIndex = index + let prevPopup = prevPopups[index] + if ( + prevPopup && + prevPopup.name === popup.popup && + prevPopup.param === popup.param + ) { + return prevPopup + } else { + if (prevPopup) prevPopup.destroy() + return popups[popup.popup](popup.param) + } + }) + for (let closedPopup of prevPopups.slice(lastIndex + 1)) { + closedPopup.destroy() + } + prevPopups = nextPopups + return nextPopups +}) diff --git a/core/package.json b/core/package.json index 588842b1..57424017 100644 --- a/core/package.json +++ b/core/package.json @@ -16,7 +16,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@logux/client": "0.21.1", + "@logux/client": "github:logux/client#next", "@logux/core": "0.9.0", "@nanostores/i18n": "0.12.2", "@nanostores/persistent": "0.10.2", diff --git a/core/pages/add.ts b/core/pages/add.ts new file mode 100644 index 00000000..3d827210 --- /dev/null +++ b/core/pages/add.ts @@ -0,0 +1,208 @@ +import debounce from 'just-debounce-it' +import { atom, computed, map } from 'nanostores' + +import { + createDownloadTask, + type DownloadTask, + ignoreAbortError, + type TextResponse +} from '../download.ts' +import { + type FeedLoader, + getLoaderForText, + type LoaderName, + loaders +} from '../loader/index.ts' +import { createPage } from './common.ts' + +const ALWAYS_HTTPS = [/^twitter\.com\//] + +export type AddLinksValue = Record< + string, + | { + error: 'invalidUrl' + state: 'invalid' + } + | { + state: 'loading' + } + | { + state: 'processed' + } + | { + state: 'unknown' + } + | { + state: 'unloadable' + } +> + +export const add = createPage('add', () => { + let $url = atom() + + let $links = map({}) + + let $candidates = atom([]) + + let $error = computed( + $links, + (links): 'invalidUrl' | 'unloadable' | undefined => { + let first = Object.keys(links)[0] + if (typeof first !== 'undefined') { + let link = links[first]! + if (link.state === 'invalid') { + return link.error + } else if (link.state === 'unloadable') { + return 'unloadable' + } + } + return undefined + } + ) + + let $sortedCandidates = computed($candidates, candidates => { + return candidates.sort((a, b) => { + return a.title.localeCompare(b.title) + }) + }) + + let $candidatesLoading = computed($links, links => { + return Object.keys(links).some(url => links[url]!.state === 'loading') + }) + + let $noResults = computed( + [$candidatesLoading, $url, $candidates, $error], + (loading, url, candidates, error) => { + return !loading && !!url && candidates.length === 0 && !error + } + ) + + function exit(): void { + $links.set({}) + $candidates.set([]) + prevTask?.destroy() + } + + let inputUrl = debounce((value: string) => { + if (value === '') { + exit() + } else { + //TODO: currentCandidate.set(undefined) + setUrl(value) + } + }, 500) + + let prevTask: DownloadTask | undefined + async function setUrl(url: string): Promise { + if (prevTask) prevTask.destroy() + if (url === $url.get()) return + inputUrl.cancel() + exit() + prevTask = createDownloadTask({ cache: 'write' }) + await addLink(prevTask, url) + } + + function getLinksFromText(response: TextResponse): string[] { + let names = Object.keys(loaders) as LoaderName[] + return names.reduce((links, name) => { + return links.concat(loaders[name].getMineLinksFromText(response)) + }, []) + } + + function getSuggestedLinksFromText(response: TextResponse): string[] { + let names = Object.keys(loaders) as LoaderName[] + return names.reduce((links, name) => { + return links.concat(loaders[name].getSuggestedLinksFromText(response)) + }, []) + } + + function addCandidate(url: string, candidate: FeedLoader): void { + if ($candidates.get().some(i => i.url === url)) return + + $links.setKey(url, { state: 'processed' }) + $candidates.set([...$candidates.get(), candidate]) + } + + async function addLink( + task: DownloadTask, + url: string, + deep = false + ): Promise { + url = url.trim() + if (url === '') return + + if (url.startsWith('http://')) { + let methodLess = url.slice('http://'.length) + if (ALWAYS_HTTPS.some(i => i.test(methodLess))) { + url = 'https://' + methodLess + } + } else if (!url.startsWith('https://')) { + if (/^\w+:/.test(url)) { + $links.setKey(url, { error: 'invalidUrl', state: 'invalid' }) + return + } else if (ALWAYS_HTTPS.some(i => i.test(url))) { + url = 'https://' + url + } else { + url = 'http://' + url + } + } + + if ($links.get()[url]) return + + if (!URL.canParse(url)) { + $links.setKey(url, { error: 'invalidUrl', state: 'invalid' }) + return + } + + $links.setKey(url, { state: 'loading' }) + try { + let response + try { + response = await task.text(url) + } catch { + $links.setKey(url, { state: 'unloadable' }) + return + } + if (!response.ok) { + $links.setKey(url, { state: 'unloadable' }) + } else { + let byText = getLoaderForText(response) + if (!deep) { + let links = getLinksFromText(response) + if (links.length > 0) { + await Promise.all(links.map(i => addLink(task, i, true))) + } else if ($candidates.get().length === 0) { + let suggested = getSuggestedLinksFromText(response) + await Promise.all(suggested.map(i => addLink(task, i, true))) + } + } + if (byText) { + addCandidate(url, byText) + } else { + $links.setKey(url, { state: 'unknown' }) + } + } + } catch (error) { + /* c8 ignore next 2 */ + ignoreAbortError(error) + } + } + + $links.listen(links => { + $url.set(Object.keys(links)[0] ?? undefined) + }) + + return { + candidate: atom(), // TODO: Remove to popups + candidatesLoading: $candidatesLoading, + error: $error, + exit, + inputUrl, + noResults: $noResults, + setUrl, + sortedCandidates: $sortedCandidates, + url: $url + } +}) + +export type AddPage = typeof add diff --git a/core/pages/common.ts b/core/pages/common.ts new file mode 100644 index 00000000..0c923410 --- /dev/null +++ b/core/pages/common.ts @@ -0,0 +1,54 @@ +import { atom, type ReadableAtom } from 'nanostores' + +import type { ParamlessRouteName, RouteName, Routes } from '../router.ts' + +type Extra = { + exit?: () => void +} + +type ParamStores = { + [Param in keyof Routes[Name]]-?: ReadableAtom +} + +export type BasePage = { + destroy(): void + readonly loading: ReadableAtom + readonly route: Name + underConstruction?: boolean // TODO: Remove after refactoring +} & ParamStores + +export interface PageCreator< + Name extends RouteName, + Rest extends Extra = Record +> { + (): BasePage & Rest + cache?: BasePage & Rest +} + +export function createPage( + route: Name, + builder: () => ParamStores & Rest +): PageCreator { + let creator: PageCreator = () => { + if (!creator.cache) { + let rest = builder() + creator.cache = { + destroy() { + creator.cache?.exit?.() + creator.cache = undefined + }, + loading: atom(false), + route, + ...rest + } + } + return creator.cache + } + return creator +} + +export function createSimplePage( + route: Name +): PageCreator { + return createPage(route, () => ({}) as ParamStores) +} diff --git a/core/pages/index.ts b/core/pages/index.ts new file mode 100644 index 00000000..0ad2dc1c --- /dev/null +++ b/core/pages/index.ts @@ -0,0 +1,55 @@ +import { atom } from 'nanostores' + +import type { RouteName, Routes } from '../router.ts' +import { add } from './add.ts' +import { + type BasePage, + createPage, + createSimplePage, + type PageCreator +} from './common.ts' + +export type { AddPage } from './add.ts' +export * from './common.ts' + +// TODO: Remove after refactoring +export function underConstruction( + route: Name, + params: (keyof Routes[Name])[] +): PageCreator { + return createPage(route, () => { + let result = {} as BasePage + for (let param of params) { + result[param] = atom() + } + result.underConstruction = true + return result + }) +} + +export const pages = { + about: underConstruction('about', []), + add, + categories: underConstruction('categories', ['feed']), + download: underConstruction('download', []), + export: underConstruction('export', []), + fast: underConstruction('fast', ['category', 'post', 'since']), + feeds: underConstruction('feeds', []), + home: underConstruction('home', []), + import: underConstruction('import', []), + interface: underConstruction('interface', []), + notFound: createSimplePage('notFound'), + profile: underConstruction('profile', []), + refresh: underConstruction('refresh', []), + settings: underConstruction('settings', []), + signin: underConstruction('signin', []), + slow: underConstruction('slow', ['feed', 'page', 'post']), + start: underConstruction('start', []), + welcome: underConstruction('welcome', []) +} satisfies { + [Name in RouteName]: PageCreator +} + +export type Pages = typeof pages + +export type Page = ReturnType diff --git a/core/popups/common.ts b/core/popups/common.ts new file mode 100644 index 00000000..c4b6af65 --- /dev/null +++ b/core/popups/common.ts @@ -0,0 +1,82 @@ +import { atom, type ReadableAtom } from 'nanostores' + +import { isNotFoundError } from '../not-found.ts' +import type { PopupName } from '../router.ts' + +type Extra = { + destroy: () => void +} + +export type BasePopup< + Name extends PopupName = PopupName, + Loading extends boolean = boolean, + NotFound extends boolean = boolean +> = { + destroy(): void + readonly loading: ReadableAtom + readonly name: Name + readonly notFound: NotFound + readonly param: string +} + +export interface PopupCreator< + Name extends PopupName, + Rest extends Extra = Extra +> { + ( + param: string + ): + | (BasePopup & Rest) + | BasePopup + | BasePopup +} + +export type LoadedPopup> = Extract< + ReturnType, + { loading: ReadableAtom; notFound: false } +> + +export function definePopup( + name: Name, + builder: (param: string) => Promise +): PopupCreator { + let creator: PopupCreator = param => { + let destroyed = false + let rest: Rest | undefined + let loading = atom(true) + let popup = { + destroy() { + destroyed = true + rest?.destroy() + }, + loading, + name, + notFound: false, + param + } + + loading.set(true) + builder(param) + .then(extra => { + rest = extra + if (destroyed) extra.destroy() + for (let i in rest) { + // @ts-expect-error Too complex case for TypeScript + popup[i] = extra[i] + } + loading.set(false) + }) + .catch((e: unknown) => { + if (isNotFoundError(e)) { + popup.notFound = true + popup.destroy() + loading.set(false) + } else { + /* c8 ignore next 2 */ + throw e + } + }) + return popup as ReturnType> + } + return creator +} diff --git a/core/popups/feed-url.ts b/core/popups/feed-url.ts new file mode 100644 index 00000000..fe38d6fe --- /dev/null +++ b/core/popups/feed-url.ts @@ -0,0 +1,50 @@ +import { atom } from 'nanostores' + +import { createDownloadTask } from '../download.ts' +import { type FeedValue, getFeeds } from '../feed.ts' +import { getLoaderForText } from '../loader/index.ts' +import { NotFoundError } from '../not-found.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const feedUrl = definePopup('feedUrl', async url => { + let task = createDownloadTask({ cache: 'read' }) + let response = await task.text(url) + + let candidate = getLoaderForText(response) + if (!candidate) { + throw new NotFoundError() + } + + let posts = candidate.loader.getPosts(task, url, response) + let $feed = atom() + + let feedsFilter = getFeeds({ url }) + let unbindFeed = (): void => {} + let unbindFeeds = feedsFilter.subscribe(feeds => { + if (!feeds.isLoading) { + let needed = feeds.list.find(feed => feed.url === url) + if (needed) { + let $needed = feeds.stores.get(needed.id)! + unbindFeed = $needed.subscribe(feed => { + if (!feed.isLoading) { + $feed.set(feed) + } + }) + } else { + $feed.set(undefined) + } + } + }) + + return { + destroy() { + task.destroy() + unbindFeeds() + unbindFeed() + }, + feed: $feed, + posts + } +}) + +export type FeedUrlPopup = LoadedPopup diff --git a/core/popups/feed.ts b/core/popups/feed.ts new file mode 100644 index 00000000..186e0e53 --- /dev/null +++ b/core/popups/feed.ts @@ -0,0 +1,14 @@ +import { getFeed, getFeedLatestPosts } from '../feed.ts' +import { waitSyncLoading } from '../lib/stores.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const feed = definePopup('feed', async id => { + let $feed = await waitSyncLoading(getFeed(id)) + return { + destroy() {}, + feed: $feed, + posts: getFeedLatestPosts($feed.get()) + } +}) + +export type FeedPopup = LoadedPopup diff --git a/core/popups/index.ts b/core/popups/index.ts new file mode 100644 index 00000000..3654d1a2 --- /dev/null +++ b/core/popups/index.ts @@ -0,0 +1,24 @@ +import type { PopupName } from '../router.ts' +import type { PopupCreator } from './common.ts' +import { feedUrl } from './feed-url.ts' +import { feed } from './feed.ts' +import { post } from './post.ts' + +export * from './common.ts' +export type { FeedUrlPopup } from './feed-url.ts' +export type { FeedPopup } from './feed.ts' +export type { PostPopup } from './post.ts' + +export const popups = { + feed, + feedUrl, + post +} satisfies { + [Name in PopupName]: PopupCreator +} + +export type PopupCreators = typeof popups + +export type Popup = ReturnType< + PopupCreators[Name] +> diff --git a/core/popups/post.ts b/core/popups/post.ts new file mode 100644 index 00000000..6dd38145 --- /dev/null +++ b/core/popups/post.ts @@ -0,0 +1,13 @@ +import { waitSyncLoading } from '../lib/stores.ts' +import { getPost } from '../post.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const post = definePopup('post', async id => { + let $post = getPost(id) + return { + destroy() {}, + post: await waitSyncLoading($post) + } +}) + +export type PostPopup = LoadedPopup diff --git a/core/preview.ts b/core/preview.ts index 12deb273..e8a00816 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { loadValue } from '@logux/client' import debounce from 'just-debounce-it' import { nanoid } from 'nanoid' @@ -12,7 +13,7 @@ import { isMobile, onEnvironment } from './environment.ts' import { addFeed, type FeedValue, getFeeds } from './feed.ts' import { readonlyExport } from './lib/stores.ts' import { type LoaderName, loaders } from './loader/index.ts' -import { addPost, processOriginPost } from './post.ts' +import { addPost, type OriginPost, processOriginPost } from './post.ts' import type { PostsList } from './posts-list.ts' import { router } from './router.ts' @@ -108,7 +109,7 @@ export function clearPreview(): void { $added.set(undefined) $posts.set(undefined) postsCache.clear() - task.abortAll() + task.destroy() task = createDownloadTask() } @@ -134,9 +135,7 @@ function getLoaderForUrl(url: string): false | PreviewCandidate { return false } -export function getLoaderForText( - response: TextResponse -): false | PreviewCandidate { +function getLoaderForText(response: TextResponse): false | PreviewCandidate { let names = Object.keys(loaders) as LoaderName[] let parsed = new URL(response.url) for (let name of names) { @@ -257,6 +256,7 @@ export async function createFeedFromUrl( let posts = loaders[candidate.loader].getPosts(task, url, candidate.text) $posts.set(posts) + // @ts-expect-error Legacy file which we will delete soon let page = await loadValue(posts) let lastPost = page.list[0] @@ -319,6 +319,7 @@ export function setPreviewCandidate(url: string): void { export async function addPreviewCandidate(): Promise { let url = $candidate.get() if (url) { + // @ts-expect-error Legacy file which we will delete soon let page = await loadValue($posts.get()!) let lastPost = page.list[0] let candidate = $candidates.get().find(i => i.url === url)! @@ -333,7 +334,7 @@ export async function addPreviewCandidate(): Promise { url }) if (lastPost) { - await addPost(processOriginPost(lastPost, feedId, 'fast')) + await addPost(processOriginPost(lastPost as OriginPost, feedId, 'fast')) } } } @@ -348,7 +349,11 @@ onEnvironment(({ openRoute }) => { previewUrl.listen(link => { let page = router.get() if (page.route === 'add' && page.params.url !== link) { - openRoute({ params: { url: link }, route: 'add' }) + openRoute({ + params: { candidate: undefined, url: link }, + popups: [], + route: 'add' + }) } }), router.subscribe(({ params, route }) => { @@ -367,7 +372,8 @@ onEnvironment(({ openRoute }) => { setPreviewCandidate(params.candidate) } else { openRoute({ - params: { url: params.url }, + params: { candidate: undefined, url: params.url }, + popups: [], route: 'add' }) } @@ -398,6 +404,7 @@ onEnvironment(({ openRoute }) => { candidate: candidateUrl, url: page.params.url }, + popups: [], route: 'add' }) } diff --git a/core/refresh.ts b/core/refresh.ts index 0c1359cd..85ece621 100644 --- a/core/refresh.ts +++ b/core/refresh.ts @@ -136,5 +136,5 @@ export function stopRefreshing(): void { if (!$isRefreshing.get()) return $isRefreshing.set(false) queue.stop() - task.abortAll() + task.destroy() } diff --git a/core/request.ts b/core/request.ts index 98f60b78..e17c4a19 100644 --- a/core/request.ts +++ b/core/request.ts @@ -29,6 +29,7 @@ interface RequestExpect { let requestExpects: RequestExpect[] = [] +/* c8 ignore start */ let fetchMock: RequestMethod = async (url, opts = {}) => { let expect = requestExpects.shift() if (!expect) { @@ -106,3 +107,4 @@ export function checkAndRemoveRequestMock(): void { } setRequestMethod(fetch) } +/* c8 ignore stop */ diff --git a/core/router.ts b/core/router.ts index 8684109b..bb38e5e2 100644 --- a/core/router.ts +++ b/core/router.ts @@ -9,7 +9,7 @@ import { slowCategories } from './slow.ts' export interface Routes { about: {} - add: { candidate?: string; url?: string } + add: { candidate: string | undefined; url: string | undefined } categories: { feed?: string } download: {} export: { format?: string } @@ -28,6 +28,12 @@ export interface Routes { welcome: {} } +export const popupNames = { feed: true, feedUrl: true, post: true } + +export type PopupName = keyof typeof popupNames + +export type PopupRoute = { param: string; popup: PopupName } + export type RouteName = keyof Routes type EmptyObject = Record @@ -44,7 +50,12 @@ export type ParamlessRouteName = { }[RouteName] export type Route = Name extends string - ? { params: Routes[Name]; redirect?: boolean; route: Name } + ? { + params: Routes[Name] + popups: PopupRoute[] + redirect?: boolean + route: Name + } : never type StringParams = { @@ -52,7 +63,7 @@ type StringParams = { } export type BaseRoute = Name extends string - ? { params: StringParams; route: Name } + ? { hash: string; params: StringParams; route: Name } : never export type BaseRouter = ReadableAtom @@ -70,13 +81,12 @@ const SETTINGS = new Set([ const ORGANIZE = new Set(['add', 'categories']) -function open(route: ParamlessRouteName | Route): Route { - if (typeof route === 'string') route = { params: {}, route } as Route - return route +function open(route: ParamlessRouteName): Route { + return { params: {}, popups: [], route } } -function redirect(route: ParamlessRouteName | Route): Route { - return { ...open(route), redirect: true } +function redirect(route: Route): Route { + return { ...route, redirect: true } } function validateNumber( @@ -92,7 +102,25 @@ function validateNumber( } } -let $router = atom({ params: {}, route: 'home' }) +let $router = atom({ params: {}, popups: [], route: 'home' }) + +function checkPopupName( + popup: string | undefined +): popup is keyof typeof popupNames { + return !!popup && popup in popupNames +} + +function parsePopups(hash: string): PopupRoute[] { + let popups: PopupRoute[] = [] + let parts = hash.split(',') + for (let part of parts) { + let [popup, param] = part.split('=', 2) + if (checkPopupName(popup) && param) { + popups.push({ param, popup }) + } + } + return popups +} export const router = readonlyExport($router) @@ -103,26 +131,40 @@ onEnvironment(({ baseRouter }) => { (route, user, withFeeds, fast, slowUnread) => { if (!route) { return open('notFound') - } else if (user) { + } else if (!user) { + if (!GUEST.has(route.route)) { + return open('start') + } else { + return { params: route.params, popups: [], route: route.route } + } + } else { + let popups = parsePopups(route.hash) if (GUEST.has(route.route) || route.route === 'home') { if (withFeeds) { - return redirect({ params: {}, route: 'slow' }) + return redirect({ params: {}, popups, route: 'slow' }) } else { - return redirect('welcome') + return redirect(open('welcome')) } } else if (route.route === 'welcome' && withFeeds) { - return redirect('slow') + return redirect(open('slow')) } else if (route.route === 'settings') { - return redirect('interface') + return redirect(open('interface')) } else if (route.route === 'feeds') { - return redirect('add') + return redirect({ + params: { candidate: undefined, url: undefined }, + popups, + route: 'add' + }) } else if (route.route === 'fast') { + // TODO: move to new fast/slow migration to pages if (!route.params.category && !fast.isLoading) { return redirect({ params: { category: fast.categories[0].id }, + popups, route: 'fast' }) } + // TODO: remove check from loader on fast/slow migration to pages if (route.params.category && !fast.isLoading) { let category = fast.categories.find( i => i.id === route.params.category @@ -134,15 +176,17 @@ onEnvironment(({ baseRouter }) => { if (route.params.since) { return validateNumber(route.params.since, since => { return { - ...route, params: { ...route.params, since - } + }, + popups, + route: route.route } }) } } else if (route.route === 'slow') { + // TODO: move to new fast/slow migration to pages if (!route.params.feed && !slowUnread.isLoading) { let firstCategory = slowUnread.tree[0] if (firstCategory) { @@ -151,6 +195,7 @@ onEnvironment(({ baseRouter }) => { if (feedData) { return redirect({ params: { feed: feedData[0].id || '' }, + popups, route: 'slow' }) } @@ -160,32 +205,33 @@ onEnvironment(({ baseRouter }) => { if (route.params.page) { return validateNumber(route.params.page, page => { return { - ...route, params: { ...route.params, page - } + }, + popups, + route: route.route } }) } else { - return open({ + return { params: { ...route.params, page: 1 }, + popups, route: 'slow' - }) + } } } - } else if (!GUEST.has(route.route)) { - return open('start') + return { params: route.params, popups, route: route.route } } - return route }, (oldRoute, newRoute) => { return ( oldRoute.route === newRoute.route && - JSON.stringify(oldRoute.params) === JSON.stringify(newRoute.params) + JSON.stringify(oldRoute.params) === JSON.stringify(newRoute.params) && + JSON.stringify(oldRoute.popups) === JSON.stringify(newRoute.popups) ) } ) @@ -206,32 +252,38 @@ export function backToFirstStep(): void { } } +// TODO: Remove on moving to popups export const backRoute = computed( - router, + $router, ({ params, route }): Route | undefined => { if (route === 'add' && params.candidate) { return { - params: { url: params.url }, + params: { candidate: undefined, url: params.url }, + popups: [], route: 'add' } } else if (route === 'categories' && params.feed) { return { params: {}, + popups: [], route: 'categories' } } else if (route === 'fast' && params.post) { return { params: { category: params.category }, + popups: [], route: 'fast' } } else if (route === 'slow' && params.post) { return { params: { feed: params.feed }, + popups: [], route: 'slow' } } else if (route === 'export' && params.format) { return { params: { format: undefined }, + popups: [], route: 'export' } } @@ -239,8 +291,21 @@ export const backRoute = computed( ) export function onNextRoute(cb: (route: Route) => void): void { - let unbind = router.listen(route => { + let unbind = $router.listen(route => { unbind() cb(route) }) } + +export function addPopup( + hash: string, + popup: PopupName, + param: string +): string { + let add = `${popup}=${param}` + return hash === '' ? add : `${hash},${add}` +} + +export function removeLastPopup(hash: string): string { + return hash.split(',').slice(0, -1).join(',') +} diff --git a/core/slow.ts b/core/slow.ts index 3e6abc48..6a5306c1 100644 --- a/core/slow.ts +++ b/core/slow.ts @@ -193,6 +193,7 @@ onEnvironment(({ openRoute }) => { if (notSynced(router.get())) { openRoute({ params: { feed, page }, + popups: [], route: 'slow' }) } diff --git a/core/test/client.test.ts b/core/test/client.test.ts deleted file mode 100644 index 00681ee3..00000000 --- a/core/test/client.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { spyOn } from 'nanospy' -import { cleanStores, keepMount } from 'nanostores' -import { equal, match, throws } from 'node:assert' -import { afterEach, test } from 'node:test' - -import { client, getClient, userId } from '../index.ts' -import { enableClientTest } from './utils.ts' - -enableClientTest() - -afterEach(() => { - cleanStores(client) -}) - -test('re-create client on user ID changes', () => { - userId.set(undefined) - keepMount(client) - equal(client.get(), undefined) - - userId.set('10') - match(client.get()!.clientId, /^10:/) - - let destroy10 = spyOn(client.get()!, 'destroy') - userId.set('11') - match(client.get()!.clientId, /^11:/) - equal(destroy10.callCount, 1) - - let destroy11 = spyOn(client.get()!, 'destroy') - userId.set(undefined) - equal(client.get(), undefined) - equal(destroy11.callCount, 1) -}) - -test('has helper for client area', () => { - userId.set(undefined) - throws(() => { - getClient() - }, 'Error: No Slow Reader client') - - userId.set('10') - match(getClient().clientId, /^10:/) -}) diff --git a/core/test/current-page.test.ts b/core/test/current-page.test.ts new file mode 100644 index 00000000..9abb0731 --- /dev/null +++ b/core/test/current-page.test.ts @@ -0,0 +1,125 @@ +import { cleanStores, keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { currentPage, pages, router, setBaseTestRoute } from '../index.ts' +import { cleanClientTest, enableClientTest } from './utils.ts' + +let addPage = pages.add + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + pages.add = addPage + await cleanClientTest() + cleanStores(currentPage) +}) + +test('synchronies router with page', () => { + keepMount(currentPage) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(currentPage.get(), pages.notFound()) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(currentPage.get(), pages.add()) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(currentPage.get(), pages.notFound()) +}) + +test('calls events', () => { + keepMount(currentPage) + let events = '' + let originAdd = pages.add + + pages.add = () => { + events += 'create ' + let add = originAdd() + let originExit = add.exit + add.exit = () => { + originExit() + events += 'exit ' + } + let originDestroy = add.destroy + add.destroy = () => { + originDestroy() + events += 'destroy ' + } + return add + } + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(currentPage.get().route, 'notFound') + equal(events, 0) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(currentPage.get().route, 'add') + equal(events, 'create ') + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(currentPage.get().route, 'notFound') + equal(events, 'create exit destroy ') + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(currentPage.get().route, 'add') + equal(events, 'create exit destroy create ') + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(currentPage.get().route, 'notFound') + equal(events, 'create exit destroy create exit destroy ') +}) + +test('synchronizes params', async () => { + keepMount(currentPage) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(pages.add().url.get(), undefined) + equal(pages.add().candidate.get(), undefined) + + pages.add().url.set('https://example.com') + await setTimeout(1) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: 'https://example.com' }, + popups: [], + route: 'add' + }) + equal(pages.add().url.get(), 'https://example.com') + equal(pages.add().candidate.get(), undefined) + + setBaseTestRoute({ + params: { candidate: undefined, url: 'https://other.com' }, + route: 'add' + }) + await setTimeout(1) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: 'https://other.com' }, + popups: [], + route: 'add' + }) + equal(pages.add().url.get(), 'https://other.com') + equal(pages.add().candidate.get(), undefined) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + pages.add().url.set('https://example.com') + await setTimeout(1) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) +}) + +test('has under construction pages', () => { + equal(pages.slow().underConstruction, true) +}) diff --git a/core/test/download.test.ts b/core/test/download.test.ts index c6b6ffc6..f7fdea7f 100644 --- a/core/test/download.test.ts +++ b/core/test/download.test.ts @@ -83,7 +83,7 @@ test('aborts requests', async () => { await reply1(200) equal(calls, '1:ok ') - task1.abortAll() + task1.destroy() await setTimeout(10) equal(calls, '1:ok 2:AbortError 3:AbortError ') @@ -119,7 +119,7 @@ test('can download text by keeping eyes on abort signal', async () => { let response2 = task.text('https://example.com') await setTimeout(10) - task.abortAll() + task.destroy() sendText?.('Done') await rejects(response2, (e: Error) => e.name === 'AbortError') }) @@ -179,7 +179,7 @@ test('parses JSON content', () => { test('has helper to ignore abort errors', async () => { let task = createDownloadTask() - task.abortAll() + task.destroy() let error1 = new Error('Test') throws(() => { @@ -207,7 +207,7 @@ test('has helper to ignore abort errors', async () => { task.text('https://example.com').catch(e => { error3 = e }) - task.abortAll() + task.destroy() await setTimeout(10) ignoreAbortError(error3) @@ -254,3 +254,28 @@ test('detects content type', () => { 'application/rss+xml' ) }) + +test('has cache by request', async () => { + let write = createDownloadTask({ cache: 'write' }) + let read = createDownloadTask({ cache: 'read' }) + let none = createDownloadTask() + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await write.text('https://example.com/1')).text, '1') + equal((await write.text('https://example.com/1')).text, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await none.text('https://example.com/1')).text, '1') + + write.destroy() + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') +}) diff --git a/core/test/environment.test.ts b/core/test/environment.test.ts deleted file mode 100644 index 2f701e55..00000000 --- a/core/test/environment.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { atom } from 'nanostores' -import { deepStrictEqual, equal, throws } from 'node:assert' -import { test } from 'node:test' - -import { - getEnvironment, - getTestEnvironment, - onEnvironment, - setupEnvironment -} from '../index.ts' - -test('throws on current environment if it is not set', () => { - throws(() => { - getEnvironment() - }, 'Error: No Slow Reader environment') -}) - -test('runs callback when environment will be set', () => { - let calls: string[] = [] - let cleans = '' - onEnvironment(env => { - calls.push(env.locale.get()) - return () => { - cleans += '1' - } - }) - onEnvironment(() => { - return [ - () => { - cleans += '2' - }, - () => { - cleans += '3' - } - ] - }) - deepStrictEqual(calls, []) - - setupEnvironment({ ...getTestEnvironment(), locale: atom('fr') }) - deepStrictEqual(calls, ['fr']) - equal(cleans, '') - - setupEnvironment({ ...getTestEnvironment(), locale: atom('ru') }) - deepStrictEqual(calls, ['fr', 'ru']) - equal(cleans, '123') - - let after: string[] = [] - onEnvironment(env => { - after.push(env.locale.get()) - }) - deepStrictEqual(after, ['ru']) - equal(cleans, '123') -}) - -test('returns current environment', () => { - let locale = atom('fr') - setupEnvironment({ ...getTestEnvironment(), locale }) - equal(getEnvironment().locale, locale) -}) diff --git a/core/test/fast.test.ts b/core/test/fast.test.ts index 738c50c2..7978ccca 100644 --- a/core/test/fast.test.ts +++ b/core/test/fast.test.ts @@ -412,6 +412,7 @@ test('syncs fast category and since with URL', async () => { await markReadAndLoadNextFastPosts() deepStrictEqual(router.get(), { params: { category: category1, since: 5000 }, + popups: [], route: 'fast' }) diff --git a/core/test/feed.test.ts b/core/test/feed.test.ts deleted file mode 100644 index c7ed5f2c..00000000 --- a/core/test/feed.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ensureLoaded } from '@logux/client' -import { restoreAll, spyOn } from 'nanospy' -import { keepMount } from 'nanostores' -import { deepStrictEqual, equal } from 'node:assert' -import { afterEach, beforeEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' - -import { - addFeed, - addFilterForFeed, - addPost, - changeFeed, - changeFilter, - createPostsList, - deleteFeed, - deleteFilter, - type FilterValue, - getFeed, - getFeedLatestPosts, - getPosts, - hasFeeds, - loaders, - loadFeed, - loadFeeds, - testFeed, - testPost -} from '../index.ts' -import { cleanClientTest, enableClientTest } from './utils.ts' - -beforeEach(() => { - enableClientTest() -}) - -afterEach(async () => { - restoreAll() - await cleanClientTest() -}) - -test('adds, loads, changes and removes feed', async () => { - deepStrictEqual(await loadFeeds(), []) - - let id = await addFeed({ - categoryId: 'general', - loader: 'rss', - reading: 'fast', - title: 'RSS', - url: 'https://example.com/' - }) - equal(typeof id, 'string') - let added = await loadFeeds() - equal(added.length, 1) - equal(added[0]!.title, 'RSS') - - equal(await loadFeed(id), added[0]) - - let feed = getFeed(id) - keepMount(feed) - equal(feed.get(), added[0]) - - await changeFeed(id, { title: 'New title' }) - equal(ensureLoaded(feed.get()).title, 'New title') - - await deleteFeed(id) - deepStrictEqual(await loadFeeds(), []) - - equal(await loadFeed('unknown'), undefined) -}) - -test('removes feed posts too', async () => { - let posts = getPosts() - keepMount(posts) - - let feed1 = await addFeed(testFeed()) - let feed2 = await addFeed(testFeed()) - await addPost(testPost({ feedId: feed1 })) - await addPost(testPost({ feedId: feed1 })) - let post3 = await addPost(testPost({ feedId: feed2 })) - let store1 = getFeed(feed1) - - await deleteFeed(feed1) - equal(store1.deleted, true) - deepStrictEqual( - ensureLoaded(posts.get()).list.map(i => i.id), - [post3] - ) -}) - -test('loads latest posts', async () => { - let page = createPostsList([], undefined) - let getPage = spyOn(loaders.rss, 'getPosts', () => page) - - let id = await addFeed( - testFeed({ - loader: 'rss', - url: 'https://example.com/' - }) - ) - let feed = await loadFeed(id) - - equal(getFeedLatestPosts(feed!), page) - equal(getPage.calls.length, 1) - equal(getPage.calls[0]![1], 'https://example.com/') -}) - -test('shows that user has any feeds', async () => { - await cleanClientTest() - enableClientTest() - equal(hasFeeds.get(), undefined) - await setTimeout(10) - equal(hasFeeds.get(), false) - - let id = await addFeed(testFeed()) - equal(hasFeeds.get(), true) - - await deleteFeed(id) - equal(hasFeeds.get(), false) -}) - -test('change feed and post reading status', async () => { - let feedId = await addFeed(testFeed()) - let feed = getFeed(feedId) - let posts = getPosts() - keepMount(feed) - keepMount(posts) - await addPost(testPost({ feedId, title: 'Feed post' })) - await addPost(testPost({ feedId, title: 'Filter post' })) - - let filter: FilterValue = { - action: 'fast', - feedId, - id: '1', - priority: 100, - query: 'include(Filter)' - } - - let filterId = await addFilterForFeed((await loadFeed(feedId))!) - await changeFilter(filterId, filter) - - equal(ensureLoaded(feed.get()).reading, 'fast') - equal(ensureLoaded(posts.get()).list[0]?.reading, 'fast') - - await changeFeed(feedId, { reading: 'slow' }) - - equal(ensureLoaded(feed.get()).reading, 'slow') - equal(ensureLoaded(posts.get()).list[0]?.reading, 'slow') - equal(ensureLoaded(posts.get()).list[1]?.reading, 'fast') - - await deleteFilter(filterId) - - equal(ensureLoaded(posts.get()).list[1]?.reading, 'slow') -}) diff --git a/core/test/i18n.test.ts b/core/test/i18n.test.ts deleted file mode 100644 index 87fe1b5f..00000000 --- a/core/test/i18n.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import './environment.ts' - -import { atom } from 'nanostores' -import { deepStrictEqual, equal } from 'node:assert' -import { test } from 'node:test' - -import { i18n, settingsMessages } from '../index.ts' -import { enableClientTest } from './utils.ts' - -test('has i18n', () => { - equal(typeof i18n, 'function') - equal(typeof settingsMessages.get().theme, 'string') - - let locale = atom('en') - let loading: string[] = [] - enableClientTest({ - locale, - translationLoader(lang) { - loading.push(lang) - return Promise.resolve({}) - } - }) - locale.set('es') - deepStrictEqual(loading, ['es']) -}) diff --git a/core/test/menu.test.ts b/core/test/menu.test.ts index fdb32f91..12a3f51e 100644 --- a/core/test/menu.test.ts +++ b/core/test/menu.test.ts @@ -28,7 +28,10 @@ afterEach(async () => { }) test('do not open menu if fast has 1 category', async () => { - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) let idA = await addCategory({ title: 'A' }) await addFeed(testFeed({ categoryId: idA, reading: 'fast' })) await addFeed(testFeed({ categoryId: idA, reading: 'fast' })) @@ -55,7 +58,10 @@ test('do not open menu if fast has 1 category', async () => { equal(isMenuOpened.get(), false) openMenu() - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) await setTimeout(10) equal(isMenuOpened.get(), true) diff --git a/core/test/not-found.test.ts b/core/test/not-found.test.ts index 64338179..472f26c0 100644 --- a/core/test/not-found.test.ts +++ b/core/test/not-found.test.ts @@ -2,7 +2,7 @@ import { LoguxUndoError } from '@logux/client' import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { notFound, setBaseTestRoute } from '../index.ts' +import { notFound, NotFoundError, setBaseTestRoute } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' let listener: (e: { reason: Error }) => void @@ -37,4 +37,9 @@ test('listens for not found error', () => { setBaseTestRoute({ params: { feed: 'another' }, route: 'categories' }) equal(notFound.get(), false) + + listener({ + reason: new NotFoundError() + }) + equal(notFound.get(), true) }) diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts new file mode 100644 index 00000000..dabed0cb --- /dev/null +++ b/core/test/pages/add.test.ts @@ -0,0 +1,396 @@ +import '../dom-parser.ts' + +import { restoreAll, spyOn } from 'nanospy' +import { keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { + checkAndRemoveRequestMock, + expectRequest, + type FeedLoader, + loaders, + mockRequest, + pages, + setBaseTestRoute +} from '../../index.ts' +import { cleanClientTest, enableClientTest } from '../utils.ts' + +beforeEach(() => { + enableClientTest() + mockRequest() + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) +}) + +afterEach(async () => { + await cleanClientTest() + restoreAll() + pages.add().exit() + checkAndRemoveRequestMock() +}) + +function equalWithText(a: FeedLoader[], b: FeedLoader[]): void { + equal(a.length, b.length) + for (let i = 0; i < a.length; i++) { + let aFix = { ...a[i], text: undefined } + let bFix = { ...b[i], text: undefined } + deepStrictEqual(aFix, bFix) + } +} + +test('empty from beginning', () => { + keepMount(pages.add().error) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().sortedCandidates) + + equal(pages.add().error.get(), undefined) + equal(pages.add().candidatesLoading.get(), false) + deepStrictEqual(pages.add().sortedCandidates.get(), []) +}) + +test('validates URL', () => { + keepMount(pages.add().error) + + pages.add().setUrl('mailto:user@example.com') + equal(pages.add().error.get(), 'invalidUrl') + + pages.add().setUrl('http://a b') + equal(pages.add().error.get(), 'invalidUrl') + + pages.add().setUrl('not URL') + equal(pages.add().error.get(), 'invalidUrl') + + equal(pages.add().noResults.get(), false) +}) + +test('uses HTTPS for specific domains', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().sortedCandidates) + spyOn(loaders.rss, 'getMineLinksFromText', () => []) + spyOn(loaders.atom, 'getMineLinksFromText', () => []) + spyOn(loaders.jsonFeed, 'getMineLinksFromText', () => []) + spyOn(loaders.rss, 'getSuggestedLinksFromText', () => []) + spyOn(loaders.atom, 'getSuggestedLinksFromText', () => []) + spyOn(loaders.jsonFeed, 'getSuggestedLinksFromText', () => []) + + expectRequest('https://twitter.com/blog').andRespond(200, '') + await pages.add().setUrl('twitter.com/blog') + + expectRequest('https://twitter.com/blog').andRespond(200, '') + await pages.add().setUrl('http://twitter.com/blog') +}) + +test('cleans state', async () => { + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + + let reply = expectRequest('http://example.com').andWait() + pages.add().setUrl('example.com') + await setTimeout(10) + + pages.add().exit() + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(reply.aborted, true) + + pages.add().setUrl('not URL') + + pages.add().exit() + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) +}) + +test('is ready for network errors', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + + let reply = expectRequest('http://example.com').andWait() + pages.add().setUrl('example.com') + + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + equal(pages.add().noResults.get(), false) + + await reply(404) + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), 'unloadable') + equal(pages.add().noResults.get(), false) + + pages.add().setUrl('') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equal(pages.add().noResults.get(), false) +}) + +test('aborts all HTTP requests on URL change', async () => { + let reply1 = expectRequest('http://example.com').andWait() + pages.add().setUrl('example.com') + + pages.add().setUrl('') + await setTimeout(10) + equal(reply1.aborted, true) + + let reply2 = expectRequest('http://other.com').andWait() + pages.add().setUrl('other.com') + + pages.add().exit() + await setTimeout(10) + equal(reply2.aborted, true) +}) + +test('detects RSS links', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + + let loadingChanges: boolean[] = [] + pages.add().candidatesLoading.subscribe(() => { + loadingChanges.push(pages.add().candidatesLoading.get()) + }) + + let replyHtml = expectRequest('http://example.com').andWait() + pages.add().setUrl('example.com') + await setTimeout(10) + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), false) + + let replyRss = expectRequest('http://example.com/news').andWait() + replyHtml( + 200, + '' + + '' + + '' + ) + await setTimeout(10) + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), false) + + let rss = ' News ' + replyRss(200, rss, 'application/rss+xml') + await setTimeout(10) + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + deepStrictEqual(loadingChanges, [false, true, false]) + equalWithText(pages.add().sortedCandidates.get(), [ + { + loader: loaders.rss, + name: 'rss', + title: 'News', + url: 'http://example.com/news' + } + ]) + equal(pages.add().noResults.get(), false) +}) + +test('is ready for empty title', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + + expectRequest('http://example.com').andRespond( + 200, + ` + + ` + ) + let rss = '' + expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') + + await pages.add().setUrl('example.com') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ + { + loader: loaders.atom, + name: 'atom', + title: '', + url: 'http://other.com/atom' + } + ]) +}) + +test('ignores duplicate links', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + + expectRequest('http://example.com').andRespond( + 200, + ` + + Feed + ` + ) + let rss = 'Feed' + expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') + + pages.add().setUrl('example.com') + await setTimeout(10) + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ + { + loader: loaders.atom, + name: 'atom', + title: 'Feed', + url: 'http://other.com/atom' + } + ]) +}) + +test('looks for popular RSS, Atom and JsonFeed places', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + let atom = '' + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(200, atom, 'text/xml') + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + + pages.add().setUrl('example.com') + + await setTimeout(10) + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ + { + loader: loaders.atom, + name: 'atom', + title: '', + url: 'http://example.com/atom' + } + ]) +}) + +test('shows if unknown URL', async () => { + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + keepMount(pages.add().noResults) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + + await pages.add().setUrl('example.com') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), true) +}) + +test('always keep the same order of candidates', async () => { + keepMount(pages.add().sortedCandidates) + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond( + 200, + 'Atom', + 'application/rss+xml' + ) + expectRequest('http://example.com/feed.json').andRespond( + 200, + '{ "version": "https://jsonfeed.org/version/1.1", "title": "JsonFeed", "items": [] }', + 'application/json' + ) + expectRequest('http://example.com/rss').andRespond( + 200, + 'RSS', + 'application/rss+xml' + ) + await pages.add().setUrl('example.com') + + deepStrictEqual( + pages + .add() + .sortedCandidates.get() + .map(i => i.title), + ['Atom', 'JsonFeed', 'RSS'] + ) + + pages.add().exit() + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + let atom = expectRequest('http://example.com/atom').andWait() + let jsonFeed = expectRequest('http://example.com/feed.json').andWait() + expectRequest('http://example.com/rss').andRespond( + 200, + 'RSS', + 'application/rss+xml' + ) + pages.add().setUrl('example.com') + await setTimeout(10) + atom(200, 'Atom', 'application/rss+xml') + jsonFeed( + 200, + '{ "version": "https://jsonfeed.org/version/1.1", "title": "JsonFeed", "items": [] }', + 'application/json' + ) + await setTimeout(10) + + deepStrictEqual( + pages + .add() + .sortedCandidates.get() + .map(i => i.title), + ['Atom', 'JsonFeed', 'RSS'] + ) +}) + +test('changes URL during typing in the field', async () => { + equal(pages.add().url.get(), undefined) + + pages.add().setUrl('') + equal(pages.add().url.get(), undefined) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + pages.add().setUrl('example.com') + equal(pages.add().url.get(), 'http://example.com') + await setTimeout(10) + + pages.add().inputUrl('other') + equal(pages.add().url.get(), 'http://example.com') + + pages.add().inputUrl('other.') + equal(pages.add().url.get(), 'http://example.com') + + expectRequest('http://other.net').andRespond(200, 'Nothing') + expectRequest('http://other.net/feed').andRespond(404) + expectRequest('http://other.net/atom').andRespond(404) + expectRequest('http://other.net/feed.json').andRespond(404) + expectRequest('http://other.net/rss').andRespond(404) + pages.add().inputUrl('other.net') + await setTimeout(500) + equal(pages.add().url.get(), 'http://other.net') + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + pages.add().inputUrl('other.net/some') + pages.add().setUrl('example.com') + await setTimeout(500) + equal(pages.add().url.get(), 'http://example.com') + + pages.add().inputUrl('') + await setTimeout(500) + equal(pages.add().url.get(), undefined) +}) diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts new file mode 100644 index 00000000..ade9f4dd --- /dev/null +++ b/core/test/popups/feed-url.test.ts @@ -0,0 +1,139 @@ +import '../dom-parser.ts' + +import { cleanStores, keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' + +import { + addFeed, + changeFeed, + checkAndRemoveRequestMock, + deleteFeed, + expectRequest, + mockRequest, + openedPopups, + setBaseTestRoute, + testFeed, + waitLoading +} from '../../index.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + getPopup, + openTestPopup +} from '../utils.ts' + +beforeEach(() => { + enableClientTest() + setBaseTestRoute({ params: {}, route: 'add' }) + mockRequest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) + checkAndRemoveRequestMock() +}) + +test('loads 404 for feeds by URL popup', async () => { + keepMount(openedPopups) + expectRequest('http://a.com/one').andRespond(404) + let feed1Popup = openTestPopup('feedUrl', 'http://a.com/one') + equal(openedPopups.get().length, 1) + equal(feed1Popup.name, 'feedUrl') + equal(feed1Popup.param, 'http://a.com/one') + equal(feed1Popup.loading.get(), true) + + await waitLoading(feed1Popup.loading) + equal(feed1Popup.notFound, true) + + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + expectRequest('http://a.com/two').andRespond(200, 'Nothing') + let feed2Popup = openTestPopup('feedUrl', 'http://a.com/two') + equal(openedPopups.get().length, 1) + equal(feed2Popup.param, 'http://a.com/two') + equal(feed2Popup.loading.get(), true) + + await waitLoading(feed2Popup.loading) + equal(feed2Popup.notFound, true) +}) + +test('loads feeds by URL popup', async () => { + keepMount(openedPopups) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + let popup = openTestPopup('feedUrl', 'https://a.com/atom') + equal(openedPopups.get().length, 1) + equal(popup.loading.get(), true) + + await waitLoading(popup.loading) + equal(checkLoadedPopup(popup).feed.get(), undefined) + deepStrictEqual(checkLoadedPopup(popup).posts.get().isLoading, false) + deepStrictEqual(checkLoadedPopup(popup).posts.get().list.length, 2) + deepStrictEqual(checkLoadedPopup(popup).posts.get().list[0]?.originId, '2') + + let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) + equal(checkLoadedPopup(popup).feed.get()!.url, 'https://a.com/atom') + equal(checkLoadedPopup(popup).feed.get()!.id, feedId) + equal(checkLoadedPopup(popup).feed.get()!.title, 'Test 1') + + await changeFeed(feedId, { title: 'New Test 1' }) + equal(checkLoadedPopup(popup).feed.get()!.title, 'New Test 1') + + await deleteFeed(feedId) + equal(checkLoadedPopup(popup).feed.get(), undefined) +}) + +test('destroys replaced popups and keep unchanged', async () => { + keepMount(openedPopups) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + + setBaseTestRoute({ + hash: `feedUrl=https://a.com/atom,feedUrl=https://a.com/atom`, + params: {}, + route: 'add' + }) + equal(openedPopups.get().length, 2) + let popup1 = getPopup('feedUrl', 0) + let popup2 = getPopup('feedUrl', 1) + await waitLoading(popup1.loading) + equal(checkLoadedPopup(popup1).feed.get(), undefined) + equal(checkLoadedPopup(popup2).feed.get(), undefined) + + let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) + equal(checkLoadedPopup(popup1).feed.get()!.url, 'https://a.com/atom') + equal(checkLoadedPopup(popup1).feed.get()?.id, feedId) + equal(checkLoadedPopup(popup2).feed.get()?.id, feedId) + + closeLastTestPopup() + deepStrictEqual(openedPopups.get(), [popup1]) + + await deleteFeed(feedId) + equal(checkLoadedPopup(popup1).feed.get(), undefined) + equal(checkLoadedPopup(popup2).feed.get()?.id, feedId) +}) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts new file mode 100644 index 00000000..58a53df8 --- /dev/null +++ b/core/test/popups/feed.test.ts @@ -0,0 +1,71 @@ +import { cleanStores, keepMount } from 'nanostores' +import { equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' + +import { + addFeed, + addPost, + type FeedPopup, + openedPopups, + testFeed, + testPost, + waitLoading +} from '../../index.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + openTestPopup +} from '../utils.ts' + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) +}) + +test('opens feed', async () => { + keepMount(openedPopups) + equal(openedPopups.get().length, 0) + let feed = await addFeed(testFeed({ categoryId: 'general' })) + let post = await addPost(testPost({ feedId: feed })) + + let popup1 = openTestPopup('feed', feed) + equal(openedPopups.get().length, 1) + equal(popup1.name, 'feed') + equal(popup1.param, feed) + equal(popup1.loading.get(), true) + + await waitLoading(popup1.loading) + equal(popup1.loading.get(), false) + equal(popup1.notFound, false) + equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) + + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + let unknown = openTestPopup('feed', 'unknown') + equal(unknown.loading.get(), true) + + await waitLoading(unknown.loading) + equal(unknown.notFound, true) + + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + let feedPopup = openTestPopup('feed', feed) + await waitLoading(feedPopup.loading) + + let postPopup = openTestPopup('post', post) + equal(openedPopups.get().length, 2) + equal(feedPopup.loading.get(), false) + equal(postPopup.loading.get(), true) + + await waitLoading(postPopup.loading) + equal(checkLoadedPopup(feedPopup).feed.get().id, feed) + equal(checkLoadedPopup(postPopup).post.get().id, post) +}) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts new file mode 100644 index 00000000..22a35896 --- /dev/null +++ b/core/test/popups/post.test.ts @@ -0,0 +1,81 @@ +import { cleanStores, keepMount } from 'nanostores' +import { equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' + +import { + addFeed, + addPost, + openedPopups, + setBaseTestRoute, + testFeed, + testPost, + waitLoading +} from '../../index.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + getPopup, + openTestPopup +} from '../utils.ts' + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) +}) + +test('opens post', async () => { + keepMount(openedPopups) + let feed = await addFeed(testFeed({ categoryId: 'general' })) + let post1 = await addPost(testPost({ feedId: feed })) + let post2 = await addPost(testPost({ feedId: feed })) + + let popup1 = openTestPopup('post', post1) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0], popup1) + equal(popup1.name, 'post') + equal(popup1.param, post1) + equal(popup1.loading.get(), true) + + await waitLoading(popup1.loading) + equal(checkLoadedPopup(popup1).post.get().id, post1) + + let popup2 = openTestPopup('post', post1) + equal(openedPopups.get().length, 2) + equal(popup1.loading.get(), false) + equal(popup2.loading.get(), true) + + await waitLoading(popup2.loading) + equal(popup1.loading.get(), false) + equal(popup2.loading.get(), false) + + setBaseTestRoute({ + hash: `post=${post2},post=${post1}`, + params: {}, + route: 'fast' + }) + let popup3 = getPopup('post', 0) + let popup4 = getPopup('post', 1) + equal(popup3.loading.get(), true) + equal(popup4.loading.get(), false) + + await waitLoading(popup3.loading) + equal(checkLoadedPopup(popup3).post.get().id, post2) + + closeLastTestPopup() + equal(openedPopups.get().length, 1) + + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + let popup5 = openTestPopup('post', 'unknown') + equal(popup5.loading.get(), true) + + await waitLoading(popup5.loading) + equal(popup5.notFound, true) +}) diff --git a/core/test/post.test.ts b/core/test/post.test.ts index dc180f9e..2ede17bb 100644 --- a/core/test/post.test.ts +++ b/core/test/post.test.ts @@ -1,19 +1,7 @@ -import { keepMount } from 'nanostores' -import { deepStrictEqual, equal, ok } from 'node:assert' +import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { - addPost, - changePost, - deletePost, - getPost, - getPostContent, - getPostIntro, - loadPost, - loadPosts, - type OriginPost, - processOriginPost -} from '../index.ts' +import { getPostContent, getPostIntro, type OriginPost } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' beforeEach(() => { @@ -28,53 +16,6 @@ function longText(): string { return 'a'.repeat(5000) } -test('adds, loads, changes and removes posts', async () => { - deepStrictEqual(await loadPosts(), []) - - let id = await addPost({ - feedId: '1', - media: [], - originId: '1', - publishedAt: 0, - reading: 'fast' - }) - equal(typeof id, 'string') - let added = await loadPosts() - equal(added.length, 1) - equal(added[0]!.reading, 'fast') - - deepStrictEqual(await loadPost(id), added[0]) - - let post = getPost(id) - keepMount(post) - equal(post.get(), added[0]) - - await changePost(id, { reading: 'slow' }) - let changed = await loadPost(id) - equal(changed!.reading, 'slow') - - await deletePost(id) - deepStrictEqual(await loadPosts(), []) -}) - -test('processes origin post', () => { - let origin = { - media: [], - originId: '1' - } satisfies OriginPost - - ok(processOriginPost(origin, 'feed', 'fast').publishedAt >= Date.now() - 100) - equal(processOriginPost(origin, 'feed', 'fast').feedId, 'feed') - equal(processOriginPost(origin, 'feed', 'fast').reading, 'fast') - equal(typeof processOriginPost(origin, 'feed', 'fast').id, 'string') - - equal( - processOriginPost({ ...origin, publishedAt: 200 }, 'feed', 'fast') - .publishedAt, - 200 - ) -}) - test('loads post content', () => { let origin = { media: [], originId: '' } satisfies OriginPost diff --git a/core/test/posts-list.test.ts b/core/test/posts-list.test.ts deleted file mode 100644 index c1e302b0..00000000 --- a/core/test/posts-list.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { deepStrictEqual } from 'node:assert' -import { test } from 'node:test' - -import { createPostsList, type OriginPost } from '../index.ts' - -const POST1: OriginPost = { - full: '1', - media: [], - originId: '1' -} - -const POST2: OriginPost = { - full: '2', - media: [], - originId: '2' -} - -test('works with cached posts without next page', async () => { - let posts = createPostsList([POST1], undefined) - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1] - }) - - let promise = posts.next() - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1] - }) - deepStrictEqual(await promise, []) -}) - -test('works without posts', async () => { - let posts = createPostsList(undefined, () => { - return Promise.resolve([ - [POST1], - () => Promise.resolve([[POST2], undefined]) - ]) - }) - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [] - }) - - let next1 = await posts.loading - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - deepStrictEqual(next1, [POST1]) - - let promise2 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise2, [POST2]) - - let promise3 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise3, []) -}) - -test('is ready for double calls', async () => { - let posts = createPostsList(undefined, () => { - return Promise.resolve([ - [POST1], - () => Promise.resolve([[POST2], undefined]) - ]) - }) - posts.next() - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - - let promise1 = posts.next() - let promise2 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise1, [POST2]) - deepStrictEqual(await promise2, [POST2]) -}) - -test('works with cached posts with next page loader', async () => { - let posts = createPostsList([POST1], () => { - return Promise.resolve([[POST2], undefined]) - }) - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - - posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) -}) diff --git a/core/test/preview.test.ts b/core/test/preview.test.ts index 50a25223..a19095bf 100644 --- a/core/test/preview.test.ts +++ b/core/test/preview.test.ts @@ -41,7 +41,10 @@ import { cleanClientTest, enableClientTest } from './utils.ts' beforeEach(() => { enableClientTest() mockRequest() - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) }) afterEach(async () => { @@ -627,27 +630,39 @@ test('changes URL during typing in the field', async () => { }) test('syncs URL with router', () => { - deepStrictEqual(router.get(), { params: {}, route: 'add' }) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: undefined }, + popups: [], + route: 'add' + }) expectRequest('http://example.com').andRespond(404) setPreviewUrl('example.com') deepStrictEqual(router.get(), { - params: { url: 'http://example.com' }, + params: { candidate: undefined, url: 'http://example.com' }, + popups: [], route: 'add' }) expectRequest('https://other.com').andRespond(404) setPreviewUrl('https://other.com') deepStrictEqual(router.get(), { - params: { url: 'https://other.com' }, + params: { candidate: undefined, url: 'https://other.com' }, + popups: [], route: 'add' }) expectRequest('http://example.com').andRespond(404) - setBaseTestRoute({ params: { url: 'http://example.com' }, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: 'http://example.com' }, + route: 'add' + }) deepStrictEqual(previewUrl.get(), 'http://example.com') - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) deepStrictEqual(previewUrl.get(), '') expectRequest('https://new.com').andRespond(404) @@ -671,6 +686,7 @@ test('show candidate on wide screen', async () => { deepStrictEqual(router.get(), { params: { candidate: 'https://a.com/atom', url: 'https://a.com/atom' }, + popups: [], route: 'add' }) equal(currentCandidate.get(), undefined) @@ -691,7 +707,8 @@ test('do not show candidate on mobile screen', async () => { await setTimeout(10) deepStrictEqual(router.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) equal(previewCandidate.get(), undefined) @@ -709,7 +726,8 @@ test('redirect to candidates list if no current candidate', async () => { await setTimeout(10) equal(previewCandidate.get(), undefined) deepStrictEqual(router.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) }) diff --git a/core/test/request.test.ts b/core/test/request.test.ts deleted file mode 100644 index 67fc3ac7..00000000 --- a/core/test/request.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { deepStrictEqual, equal, rejects, throws } from 'node:assert' -import { afterEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' - -import { - checkAndRemoveRequestMock, - expectRequest, - mockRequest, - request, - setRequestMethod -} from '../index.ts' - -afterEach(() => { - setRequestMethod(fetch) -}) - -test('replaces request method', () => { - let result = new Promise(resolve => { - resolve(new Response()) - }) - - let calls: string[] = [] - setRequestMethod(url => { - if (typeof url === 'string') { - calls.push(url) - } - return result - }) - - equal(request('https://example.com'), result) - deepStrictEqual(calls, ['https://example.com']) -}) - -test('checks that all mock requests was called', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - expectRequest('https://two.com').andRespond(200) - - equal((await request('https://one.com')).status, 200) - throws(() => { - checkAndRemoveRequestMock() - }, /didn’t send requests: https:\/\/two.com/) -}) - -test('checks mocks order', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - expectRequest('https://two.com').andRespond(200) - - await rejects( - request('https://two.com'), - /https:\/\/one\.com instead of https:\/\/two\.com/ - ) -}) - -test('is ready for unexpected requests', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - - equal((await request('https://one.com')).status, 200) - await rejects( - request('https://one.com'), - /Unexpected request https:\/\/one.com/ - ) -}) - -test('marks requests as aborted', async () => { - mockRequest() - let reply = expectRequest('https://one.com').andWait() - - let aborted = '' - let controller = new AbortController() - request('https://one.com', { signal: controller.signal }).catch( - (e: unknown) => { - if (e instanceof Error) aborted = e.name - } - ) - - controller.abort() - await setTimeout(10) - equal(aborted, 'AbortError') - equal(reply.aborted, true) -}) - -test('sets content type', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200, 'Hi') - let response1 = await request('https://one.com') - equal(response1.headers.get('content-type'), 'text/html') - - expectRequest('https://two.com').andRespond(200, 'Hi', 'text/plain') - let response2 = await request('https://two.com') - equal(response2.headers.get('content-type'), 'text/plain') - - let reply3 = expectRequest('https://two.com').andWait() - reply3(200, 'Hi', 'text/html; charset=utf-8') - let response3 = await request('https://two.com') - equal(response3.headers.get('content-type'), 'text/html; charset=utf-8') -}) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index 411421eb..d24a369f 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -1,3 +1,4 @@ +import { keepMount } from 'nanostores' import { deepStrictEqual, equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -5,11 +6,14 @@ import { setTimeout } from 'node:timers/promises' import { addCategory, addFeed, + addPopup, addPost, backToFirstStep, deleteFeed, isGuestRoute, isOtherRoute, + openedPopups, + removeLastPopup, router, setBaseTestRoute, testFeed, @@ -28,31 +32,19 @@ afterEach(async () => { test('opens 404', () => { setBaseTestRoute(undefined) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) }) test('transforms routers for guest', () => { userId.set(undefined) setBaseTestRoute({ params: {}, route: 'home' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'start' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'start' }) setBaseTestRoute({ params: {}, route: 'slow' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'start' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'start' }) setBaseTestRoute({ params: {}, route: 'signin' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'signin' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'signin' }) }) test('transforms routers for users', () => { @@ -60,6 +52,7 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -67,12 +60,14 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: { category: 'general' }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: 'general' }, + popups: [], route: 'fast' }) setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -80,15 +75,13 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: {}, route: 'signin' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) userId.set(undefined) - deepStrictEqual(router.get(), { - params: {}, - route: 'signin' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'signin' }) }) test('transforms routers for users with feeds', async () => { @@ -96,6 +89,7 @@ test('transforms routers for users with feeds', async () => { setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -103,6 +97,7 @@ test('transforms routers for users with feeds', async () => { let id = await addFeed(testFeed()) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'slow' }) @@ -110,6 +105,7 @@ test('transforms routers for users with feeds', async () => { setBaseTestRoute({ params: {}, route: 'welcome' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'slow' }) @@ -118,6 +114,7 @@ test('transforms routers for users with feeds', async () => { await deleteFeed(id) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -129,13 +126,15 @@ test('transforms section to first section page', () => { setBaseTestRoute({ params: {}, route: 'settings' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'interface' }) setBaseTestRoute({ params: {}, route: 'feeds' }) deepStrictEqual(router.get(), { - params: {}, + params: { candidate: undefined, url: undefined }, + popups: [], redirect: true, route: 'add' }) @@ -152,6 +151,7 @@ test('transforms routers to first fast category', async () => { await setTimeout(100) deepStrictEqual(router.get(), { params: { category: idA }, + popups: [], redirect: true, route: 'fast' }) @@ -195,12 +195,14 @@ test('converts since to number', async () => { setBaseTestRoute({ params: { category: idA, since: 1000 }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: idA, since: 1000 }, + popups: [], route: 'fast' }) setBaseTestRoute({ params: { category: idA, since: '1000' }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: idA, since: 1000 }, + popups: [], route: 'fast' }) @@ -210,15 +212,13 @@ test('converts since to number', async () => { }) deepStrictEqual(router.get(), { params: { category: idA, post, since: 1000 }, + popups: [], route: 'fast' }) await setTimeout(10) setBaseTestRoute({ params: { category: idA, since: '1000k' }, route: 'fast' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) }) test('checks that category exists', async () => { @@ -231,15 +231,13 @@ test('checks that category exists', async () => { route: 'fast' }) await setTimeout(100) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) setBaseTestRoute({ params: { category: idA, since: 100 }, route: 'fast' }) await setTimeout(100) deepStrictEqual(router.get(), { params: { category: idA, since: 100 }, + popups: [], route: 'fast' }) }) @@ -252,6 +250,32 @@ test('backRoute handles export with format', () => { deepStrictEqual(router.get(), { params: { format: undefined }, + popups: [], route: 'export' }) }) + +test('has helpers for popups', () => { + equal(addPopup('', 'feed', 'id1'), 'feed=id1') + equal(addPopup('feed=id1', 'post', 'id2'), 'feed=id1,post=id2') + equal(removeLastPopup('feed=id1,post=id2'), 'feed=id1') + equal(removeLastPopup('feed=id1'), '') +}) + +test('reacts on unknown popups', () => { + userId.set('10') + keepMount(openedPopups) + equal(openedPopups.get().length, 0) + + setBaseTestRoute({ hash: `unknown=id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) + + setBaseTestRoute({ hash: `popup:id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) +}) + +test('hides popups for guest', () => { + userId.set(undefined) + setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'signin' }) + equal(openedPopups.get().length, 0) +}) diff --git a/core/test/two-steps.test.ts b/core/test/two-steps.test.ts index 3e2cd4b7..99992ca5 100644 --- a/core/test/two-steps.test.ts +++ b/core/test/two-steps.test.ts @@ -36,7 +36,10 @@ afterEach(async () => { test('works with adds route on wide screen', async () => { setIsMobile(false) - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) expectRequest('https://a.com/atom').andRespond( 200, 'Atom' + @@ -50,14 +53,18 @@ test('works with adds route on wide screen', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) }) test('works with adds route on mobile screen', async () => { setIsMobile(true) - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) expectRequest('https://a.com/atom').andRespond( 200, 'Atom' + @@ -85,6 +92,7 @@ test('works with categories route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: {}, + popups: [], route: 'categories' }) }) @@ -105,6 +113,7 @@ test('works with fast route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: { category: idA }, + popups: [], route: 'fast' }) }) @@ -125,6 +134,7 @@ test('works with slow route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: { feed }, + popups: [], route: 'slow' }) }) @@ -137,6 +147,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: { feed }, + popups: [], route: 'categories' }) @@ -144,6 +155,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: {}, + popups: [], route: 'categories' }) @@ -151,6 +163,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: {}, + popups: [], route: 'categories' }) }) diff --git a/core/test/utils.ts b/core/test/utils.ts index bc865646..6ef10108 100644 --- a/core/test/utils.ts +++ b/core/test/utils.ts @@ -1,7 +1,9 @@ -import { cleanStores } from 'nanostores' +import { cleanStores, type ReadableAtom } from 'nanostores' import { fail } from 'node:assert' import { + addPopup, + type BasePopup, Category, client, enableTestTime, @@ -9,8 +11,13 @@ import { fastCategories, Feed, Filter, + getEnvironment, getTestEnvironment, + openedPopups, + type Popup, + type PopupName, Post, + removeLastPopup, setBaseTestRoute, setupEnvironment, slowCategories, @@ -51,3 +58,63 @@ export function createPromise(): PromiseMock { } return result } + +export function openTestPopup( + popup: Name, + param: string +): Popup { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: addPopup(route.hash, popup, param) + }) + return getPopup(popup, openedPopups.get().length - 1) +} + +export function closeLastTestPopup(): void { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: removeLastPopup(route.hash) + }) +} + +export function getPopup( + name: Name, + at = 0 +): Popup { + let popups = openedPopups.get() + if (popups.length <= at) { + throw new Error( + `openedPopups has only ${popups.length} popups, but ${at} was requested` + ) + } + let popup = popups[at]! + if (popup.name !== name) { + throw new Error( + `openedPopups[${at}] has name ${popup.name}, but ${name} was requested` + ) + } + return popup as Popup +} + +export type Loaded = Extract< + SomePopup, + { loading: ReadableAtom; notFound: false } +> + +export function checkLoadedPopup( + popup: SomePopup +): Loaded { + if (popup.loading.get()) { + throw new Error('Popup is still loading') + } + if (popup.notFound) { + throw new Error('Popup data was not found') + } + return popup as Loaded +} diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index c2ceaeed..bb290407 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -5,17 +5,13 @@ import { enableTestTime, getLoaderForText, getTestEnvironment, - loaders, - type PreviewCandidate, - previewCandidates, - previewCandidatesLoading, + pages, setBaseTestRoute, - setPreviewUrl, setRequestMethod, setupEnvironment, - userId + userId, + waitLoading } from '@slowreader/core' -import type { ReadableAtom } from 'nanostores' import { readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import readline from 'node:readline' @@ -63,20 +59,6 @@ export function timeout( ]) } -export function waitFor( - store: ReadableAtom, - value: Value -): Promise { - return new Promise(resolve => { - let unbind = store.listen(state => { - if (state === value) { - unbind() - resolve() - } - }) - }) -} - interface NoFileError extends Error { code: string path: string @@ -200,13 +182,12 @@ export async function fetchAndParsePosts( semiSuccess(url, `redirect to HTML`) return } - let candidate: false | PreviewCandidate = getLoaderForText(response) + let candidate = getLoaderForText(response) if (!candidate) { error(`Can not found loader for feed ${url}`) return } - let loader = loaders[candidate.loader] - let { list } = loader.getPosts(task, url, response).get() + let { list } = candidate.loader.getPosts(task, url, response).get() if (list.length === 0) { if (badSource) { semiSuccess(url, '0 posts') @@ -233,12 +214,14 @@ export async function findRSSfromHome( feed: LoaderTestFeed, tries = 0 ): Promise { - let unbindPreview = previewCandidates.listen(() => {}) + setBaseTestRoute({ params: {}, route: 'add' }) + let addPage = pages.add() + let unbindPreview = addPage.sortedCandidates.listen(() => {}) try { let homeUrl = feed.homeUrl || getHomeUrl(feed.url) - setPreviewUrl(homeUrl) + addPage.setUrl(homeUrl) try { - await timeout(10_000, waitFor(previewCandidatesLoading, false)) + await timeout(10_000, waitLoading(addPage.candidatesLoading)) } catch (e) { if (e instanceof Error && e.message === 'Timeout' && tries > 0) { return await findRSSfromHome(feed, tries - 1) @@ -246,11 +229,13 @@ export async function findRSSfromHome( throw e } } - let normalizedUrls = previewCandidates.get().map(i => normalizeUrl(i.url)) + let normalizedUrls = addPage.sortedCandidates + .get() + .map(i => normalizeUrl(i.url)) if (normalizedUrls.includes(normalizeUrl(feed.url))) { success(`Feed ${feed.title} has feed URL at home`) return true - } else if (previewCandidates.get().length === 0) { + } else if (addPage.sortedCandidates.get().length === 0) { error( `Can’t find any feed from home URL or ${feed.title}`, `Home URL: ${homeUrl}\nFeed URL: ${feed.url}` @@ -260,7 +245,7 @@ export async function findRSSfromHome( error( `Can’t find ${feed.title} feed from home URL`, `Home URL: ${homeUrl}\n` + - `Found: ${previewCandidates + `Found: ${addPage.sortedCandidates .get() .map(i => i.url) .join('\n ')}\n` + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b048b1e..2b5d2dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,8 +112,8 @@ importers: core: dependencies: '@logux/client': - specifier: 0.21.1 - version: 0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -240,8 +240,8 @@ importers: web: dependencies: '@logux/client': - specifier: 0.21.1 - version: 0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -1055,11 +1055,12 @@ packages: peerDependencies: '@logux/core': ^0.9.0 - '@logux/client@0.21.1': - resolution: {integrity: sha512-MJJ5yRrMoT2R/BelVGl7P1Bc7kIsERLaI7cs8XZIeVWQixSYwLs7Ma+SBTjE13EyPPABqZzJNdmArTwufh85vw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + '@logux/client@https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab': + resolution: {tarball: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab} + version: 0.20.1 + engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: - '@logux/core': ^0.9.0 + '@logux/core': ^0.8.0 '@nanostores/preact': '>=0.0.0' '@nanostores/react': '>=0.0.0' '@nanostores/vue': '>=0.0.0' @@ -5248,7 +5249,7 @@ snapshots: dependencies: '@logux/core': 0.9.0 - '@logux/client@0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@logux/client@https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@logux/actions': 0.4.0(@logux/core@0.9.0) '@logux/core': 0.9.0 diff --git a/web/.size-limit.json b/web/.size-limit.json index 0d4da2d3..d23352ff 100644 --- a/web/.size-limit.json +++ b/web/.size-limit.json @@ -9,12 +9,12 @@ "!dist/500.html", "!dist/error.avif" ], - "limit": "70 KB" + "limit": "75 KB" }, { "name": "Core scripts to execute", "path": "dist/assets/index-*.js", "brotli": false, - "limit": "195 KB" + "limit": "200 KB" } ] diff --git a/web/package.json b/web/package.json index 12e36b36..dd999010 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,7 @@ "clean:build": "rm -rf dist" }, "dependencies": { - "@logux/client": "0.21.1", + "@logux/client": "github:logux/client#next", "@logux/core": "0.9.0", "@mdi/js": "7.4.47", "@nanostores/i18n": "0.12.2", diff --git a/web/pages/feeds/edit.svelte b/web/pages/feeds/edit.svelte index 7100fa3e..d048fe8a 100644 --- a/web/pages/feeds/edit.svelte +++ b/web/pages/feeds/edit.svelte @@ -72,6 +72,7 @@ if (page.route === 'categories') { openRoute({ params: {}, + popups: [], route: page.route }) } diff --git a/web/stores/router.ts b/web/stores/router.ts index 80c6687c..b2d06992 100644 --- a/web/stores/router.ts +++ b/web/stores/router.ts @@ -34,9 +34,11 @@ export const urlRouter = computed(pathRouter, path => { if (!path) { return undefined } else if (path.route === 'add') { - let params: Routes['add'] = path.params + let params: Routes['add'] = { candidate: undefined, url: undefined } + if ('url' in path.params) params.url = path.params.url if ('candidate' in path.search) params.candidate = path.search.candidate return { + hash: path.hash, params, route: path.route } @@ -45,6 +47,7 @@ export const urlRouter = computed(pathRouter, path => { if ('since' in path.search) params.since = Number(path.search.since) if ('post' in path.search) params.post = path.search.post return { + hash: path.hash, params, route: path.route } @@ -55,6 +58,7 @@ export const urlRouter = computed(pathRouter, path => { } if ('post' in path.search) params.post = path.search.post return { + hash: path.hash, params, route: path.route } @@ -90,10 +94,14 @@ function moveToSearch( return getPagePath(pathRouter, page.route, rest, search) } -export function getURL(to: ParamlessRouteName | Route): string { +export function getURL( + to: Omit | ParamlessRouteName | Route +): string { let page: Route if (typeof to === 'string') { - page = { params: {}, route: to } + page = { params: {}, popups: [], route: to } + } else if (!('popups' in to)) { + page = { ...to, popups: [] } as Route } else { page = to } diff --git a/web/stories/environment.ts b/web/stories/environment.ts index c427bc4a..59a1d8e8 100644 --- a/web/stories/environment.ts +++ b/web/stories/environment.ts @@ -9,6 +9,7 @@ import { import { atom } from 'nanostores' export const baseRouter = atom({ + hash: '', params: { category: 'general' }, route: 'fast' }) @@ -37,7 +38,7 @@ setupEnvironment({ return networkType }, openRoute(page) { - baseRouter.set(page) + baseRouter.set({ ...page, hash: '' }) }, persistentEvents: { addEventListener() {}, diff --git a/web/stories/pages/feeds/add.stories.svelte b/web/stories/pages/feeds/add.stories.svelte index 1a84a6f2..ef2877f1 100644 --- a/web/stories/pages/feeds/add.stories.svelte +++ b/web/stories/pages/feeds/add.stories.svelte @@ -82,7 +82,9 @@ - + @@ -90,14 +92,19 @@ - + @@ -105,7 +112,10 @@ @@ -114,7 +124,10 @@ ' }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -128,7 +141,10 @@ 'https://example.com/long.atom': LONG_ATOM, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -141,7 +157,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -160,7 +179,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -176,7 +198,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > diff --git a/web/stories/scene.svelte b/web/stories/scene.svelte index 0339b6b2..7258b3fb 100644 --- a/web/stories/scene.svelte +++ b/web/stories/scene.svelte @@ -2,6 +2,8 @@ import { addCategory, addFeed, + addHashToBaseRoute, + type BaseRoute, Category, type CategoryValue, clearPreview, @@ -32,7 +34,6 @@ type PostValue, refreshStatistics, type RefreshStatistics, - type Route, selectAllImportedFeeds, slowPosts, type SlowPostsValue, @@ -74,7 +75,7 @@ openedPost, refreshing = false, responses = {}, - route = { params: {}, route: 'slow' }, + route, showPagination = false, slowState = INITIAL_SLOW, unloadedFeeds = [] @@ -91,7 +92,7 @@ openedPost?: PostValue | undefined refreshing?: false | Partial responses?: Record - route?: Route + route?: BaseRoute | Omit showPagination?: boolean slowState?: SlowPostsValue unloadedFeeds?: string[] @@ -125,11 +126,14 @@ if (fast) { baseRouter.set({ + hash: '', params: { category: 'general' }, route: 'fast' }) } else { - baseRouter.set(route) + baseRouter.set( + addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } + ) } }) @@ -177,7 +181,7 @@ return () => { forceSet(isRefreshing, false) - baseRouter.set({ params: {}, route: 'slow' }) + baseRouter.set({ hash: '', params: {}, route: 'slow' }) setNetworkType(DEFAULT_NETWORK) cleanLogux() forceSet(slowPosts, INITIAL_SLOW) diff --git a/web/stories/ui/navbar.stories.svelte b/web/stories/ui/navbar.stories.svelte index ac9fd202..7ef8b039 100644 --- a/web/stories/ui/navbar.stories.svelte +++ b/web/stories/ui/navbar.stories.svelte @@ -32,7 +32,7 @@
diff --git a/web/ui/button.svelte b/web/ui/button.svelte index d5956ac5..7b9ed293 100644 --- a/web/ui/button.svelte +++ b/web/ui/button.svelte @@ -20,8 +20,7 @@ onclick?: (event: MouseEvent) => void secondary?: boolean wide?: boolean - } & - ({ href: string } | HTMLButtonAttributes) = $props() + } & ({ href: string } | HTMLButtonAttributes) = $props() {#snippet content()} diff --git a/web/ui/navbar/index.svelte b/web/ui/navbar/index.svelte index d70b5d26..f86524d0 100644 --- a/web/ui/navbar/index.svelte +++ b/web/ui/navbar/index.svelte @@ -120,7 +120,12 @@ name={$t.menu} current={isOtherRoute($router)} hotkey="m" - href={isOtherRoute($router) ? undefined : getURL('add')} + href={isOtherRoute($router) + ? undefined + : getURL({ + params: { candidate: undefined, url: undefined }, + route: 'add' + })} icon={mdiMenu} onclick={openMenu} small diff --git a/web/ui/navbar/other.svelte b/web/ui/navbar/other.svelte index c16464e6..e877ea35 100644 --- a/web/ui/navbar/other.svelte +++ b/web/ui/navbar/other.svelte @@ -21,7 +21,10 @@