diff --git a/core/index.ts b/core/index.ts index c4a0e80f..d44d9c3b 100644 --- a/core/index.ts +++ b/core/index.ts @@ -17,6 +17,8 @@ export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' export * from './not-found.ts' +export * from './page.ts' +export * from './pages/index.ts' export * from './post.ts' export * from './posts-list.ts' export * from './preview.ts' diff --git a/core/page.ts b/core/page.ts new file mode 100644 index 00000000..7864d730 --- /dev/null +++ b/core/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 page: ReadableAtom = computed(router, route => { + let currentPage = pages[route.route] + if (currentPage !== prevPage) { + if (prevPage) { + for (let unbind of unbinds) unbind() + prevPage.destroy() + } + prevPage = currentPage + + eachParam(currentPage, route, (store, param) => { + unbinds.push( + store.listen(newValue => { + let currentRoute = router.get() + if (currentRoute.route === currentPage.route) { + changeRouteParam(currentRoute, { [param]: newValue }) + } + }) + ) + }) + } + + eachParam(currentPage, route, (store, param, value) => { + if (store.get() !== value) { + store.set(value) + } + }) + + return currentPage +}) diff --git a/core/pages/add.ts b/core/pages/add.ts new file mode 100644 index 00000000..f3e6c5e0 --- /dev/null +++ b/core/pages/add.ts @@ -0,0 +1,252 @@ +import debounce from 'just-debounce-it' +import { atom, computed, map } from 'nanostores' + +import { + createDownloadTask, + type DownloadTask, + ignoreAbortError, + type TextResponse +} from '../download.ts' +import { 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 interface AddCandidate { + loader: LoaderName + text?: TextResponse + title: string + url: string +} + +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 destroy(): void { + $links.set({}) + $candidates.set([]) + prevTask?.abortAll() + } + + let inputUrl = debounce((value: string) => { + if (value === '') { + destroy() + } else { + //TODO: currentCandidate.set(undefined) + setUrl(value) + } + }, 500) + + let prevTask: DownloadTask | undefined + async function setUrl(url: string): Promise { + if (prevTask) prevTask.abortAll() + if (url === $url.get()) return + inputUrl.cancel() + destroy() + prevTask = createDownloadTask() + await addLink(prevTask, url) + } + + function getLoaderForUrl(url: string): AddCandidate | false { + let names = Object.keys(loaders) as LoaderName[] + let parsed = new URL(url) + for (let name of names) { + let title = loaders[name].isMineUrl(parsed) + // Until we will have loader for specific domain + /* c8 ignore start */ + if (typeof title === 'string') { + return { loader: name, title, url } + } + /* c8 ignore end */ + } + return false + } + + function getLoaderForText(response: TextResponse): AddCandidate | false { + 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: name, + text: response, + title: title.trim(), + url: response.url + } + } + } + } + return false + } + + 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: AddCandidate): 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 + } + + let byUrl = getLoaderForUrl(url) + + if (byUrl !== false) { + // Until we will have loader for specific domain + /* c8 ignore next */ + + addCandidate(url, byUrl) + } else { + $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 (byText) { + addCandidate(url, byText) + } else { + $links.setKey(url, { state: 'unknown' }) + } + 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))) + } + } + } + } catch (error) { + ignoreAbortError(error) + } + } + } + + $links.listen(links => { + $url.set(Object.keys(links)[0] ?? undefined) + }) + + return { + candidate: atom(), // TODO: Remove to popups + candidatesLoading: $candidatesLoading, + destroy, + error: $error, + 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..6f34f919 --- /dev/null +++ b/core/pages/common.ts @@ -0,0 +1,37 @@ +import { atom, type ReadableAtom } from 'nanostores' + +import type { ParamlessRouteName, RouteName, Routes } from '../router.ts' + +type Extra = { + destroy?: () => void +} + +type ParamStores = { + [Param in keyof Routes[Name]]-?: ReadableAtom +} + +export type BasePage = { + destroy(): void + readonly loading: ReadableAtom + readonly route: Name + underConstruction?: boolean +} & ParamStores + +export function createPage( + route: Name, + builder: () => ParamStores & Rest +): BasePage & Rest { + let rest = builder() + return { + destroy: rest.destroy ?? ((): void => {}), + loading: atom(false), + route, + ...rest + } +} + +export function createSimplePage( + route: Name +): BasePage { + return createPage(route, () => ({}) as ParamStores) +} diff --git a/core/pages/index.ts b/core/pages/index.ts new file mode 100644 index 00000000..23a24e4d --- /dev/null +++ b/core/pages/index.ts @@ -0,0 +1,50 @@ +import { atom } from 'nanostores' + +import type { RouteName, Routes } from '../router.ts' +import { add } from './add.ts' +import { type BasePage, createPage, createSimplePage } from './common.ts' + +export type { AddCandidate, AddPage } from './add.ts' +export * from './common.ts' + +// TODO: Remove after refactoring +export function underConstruction( + route: Name, + params: (keyof Routes[Name])[] +): BasePage { + 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]: BasePage +} + +export type Pages = typeof pages + +export type Page = Pages[Name] diff --git a/core/preview.ts b/core/preview.ts index 1f4e0653..3438008a 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -348,7 +348,7 @@ 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 }, route: 'add' }) } }), router.subscribe(({ params, route }) => { @@ -367,7 +367,7 @@ onEnvironment(({ openRoute }) => { setPreviewCandidate(params.candidate) } else { openRoute({ - params: { url: params.url }, + params: { candidate: undefined, url: params.url }, route: 'add' }) } diff --git a/core/router.ts b/core/router.ts index 8684109b..541fe576 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 } @@ -70,13 +70,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: {}, route } } -function redirect(route: ParamlessRouteName | Route): Route { - return { ...open(route), redirect: true } +function redirect(route: Route): Route { + return { ...route, redirect: true } } function validateNumber( @@ -108,14 +107,17 @@ onEnvironment(({ baseRouter }) => { if (withFeeds) { return redirect({ params: {}, 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 }, + route: 'add' + }) } else if (route.route === 'fast') { if (!route.params.category && !fast.isLoading) { return redirect({ @@ -168,13 +170,13 @@ onEnvironment(({ baseRouter }) => { } }) } else { - return open({ + return { params: { ...route.params, page: 1 }, route: 'slow' - }) + } } } } else if (!GUEST.has(route.route)) { @@ -206,12 +208,13 @@ 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 }, route: 'add' } } else if (route === 'categories' && params.feed) { @@ -239,7 +242,7 @@ 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) }) 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/page.test.ts b/core/test/page.test.ts new file mode 100644 index 00000000..3d2c1370 --- /dev/null +++ b/core/test/page.test.ts @@ -0,0 +1,108 @@ +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 { page, 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(page) +}) + +test('synchronies router with page', () => { + keepMount(page) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) +}) + +test('calls events', () => { + keepMount(page) + let events = 0 + pages.add = { + ...pages.add, + destroy: () => { + events += 1 + } + } + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 0) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + equal(events, 0) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 1) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + equal(events, 1) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 2) +}) + +test('synchronizes params', async () => { + keepMount(page) + 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' }, + 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' }, + 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: {}, route: 'notFound' }) +}) diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts new file mode 100644 index 00000000..25c97578 --- /dev/null +++ b/core/test/pages/add.test.ts @@ -0,0 +1,380 @@ +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 { + type AddCandidate, + checkAndRemoveRequestMock, + expectRequest, + 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.destroy() + checkAndRemoveRequestMock() +}) + +function equalWithText(a: AddCandidate[], b: AddCandidate[]): 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.destroy() + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(reply.aborted, true) + + pages.add.setUrl('not URL') + + pages.add.destroy() + 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.destroy() + 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 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) + equalWithText(pages.add.sortedCandidates.get(), [ + { + loader: '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: '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: '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: '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.destroy() + 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/preview.test.ts b/core/test/preview.test.ts index 90a25143..2a21e144 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,36 @@ test('changes URL during typing in the field', async () => { }) test('syncs URL with router', async () => { - deepStrictEqual(router.get(), { params: {}, route: 'add' }) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: undefined }, + 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' }, 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' }, 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) @@ -691,7 +703,7 @@ 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' }, route: 'add' }) equal(previewCandidate.get(), undefined) @@ -709,7 +721,7 @@ 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' }, route: 'add' }) }) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index 411421eb..49c38eb4 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -135,7 +135,7 @@ test('transforms section to first section page', () => { setBaseTestRoute({ params: {}, route: 'feeds' }) deepStrictEqual(router.get(), { - params: {}, + params: { candidate: undefined, url: undefined }, redirect: true, route: 'add' }) diff --git a/core/test/two-steps.test.ts b/core/test/two-steps.test.ts index 3e2cd4b7..7940d197 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,17 @@ 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' }, 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' + diff --git a/web/stores/router.ts b/web/stores/router.ts index 4ba611c7..9cca7cb3 100644 --- a/web/stores/router.ts +++ b/web/stores/router.ts @@ -34,7 +34,8 @@ 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 { params, 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/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/navbar/index.svelte b/web/ui/navbar/index.svelte index 279d3e3b..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 @@ -209,9 +214,9 @@ .navbar_submenu { position: relative; display: flex; - flex-direction: column; flex-grow: 1; flex-shrink: 1; + flex-direction: column; gap: 2px; padding: 8px 0 0 4px; overflow-y: auto; 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 @@