From d3cd14629b7d9430a32305bf162ba18afcbe59a0 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 6 Aug 2020 19:30:13 +0200 Subject: [PATCH 1/8] client: Switch from jQuery.ajax to fetch This will bring us one step closer to removing jQuery. Unfortunately the native promises lack built-in support for aborting so we need to clumsily return AbortController alongside them. --- assets/js/errors.js | 13 + assets/js/helpers/ajax.js | 183 +++++++++++++ assets/js/selfoss-base.js | 182 ++++++------ assets/js/selfoss-db.js | 304 ++++++++++----------- assets/js/selfoss-events-entries.js | 32 +-- assets/js/selfoss-events-entriestoolbar.js | 59 ++-- assets/js/selfoss-events-navigation.js | 91 +++--- assets/js/selfoss-events-sources.js | 161 +++++------ assets/js/selfoss-events.js | 35 +-- assets/js/selfoss-ui.js | 2 +- assets/locale/en.json | 2 + assets/package-lock.json | 10 + assets/package.json | 2 + 13 files changed, 601 insertions(+), 475 deletions(-) create mode 100644 assets/js/errors.js create mode 100644 assets/js/helpers/ajax.js diff --git a/assets/js/errors.js b/assets/js/errors.js new file mode 100644 index 0000000000..7219e184af --- /dev/null +++ b/assets/js/errors.js @@ -0,0 +1,13 @@ +export class OfflineStorageNotAvailableError extends Error { + constructor(message = 'Offline storage is not available') { + super(message); + this.name = 'OfflineStorageNotAvailableError'; + } +} + +export class TimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'TimeoutError'; + } +} diff --git a/assets/js/helpers/ajax.js b/assets/js/helpers/ajax.js new file mode 100644 index 0000000000..bf69327b42 --- /dev/null +++ b/assets/js/helpers/ajax.js @@ -0,0 +1,183 @@ +import formurlencoded from 'form-urlencoded'; +import mergeDeepLeft from 'ramda/src/mergeDeepLeft.js'; +import pipe from 'ramda/src/pipe.js'; +import { TimeoutError } from '../errors'; + +/** + * Passing this function as a Promise handler will make the promise fail when the predicate is not true. + */ +export const rejectUnless = (pred) => (response) => { + if (pred(response)) { + return response; + } else { + let err = new Error(response.statusText); + err.response = response; + throw err; + } +}; + + +/** + * fetch API considers a HTTP error a successful state. + * Passing this function as a Promise handler will make the promise fail when HTTP error occurs. + */ +export const rejectIfNotOkay = (response) => { + return rejectUnless(response => response.ok)(response); +}; + +/** + * Override fetch options. + */ +export const options = (newOpts) => (fetch) => (url, opts = {}) => fetch(url, mergeDeepLeft(opts, newOpts)); + +/** + * Override just a single fetch option. + */ +export const option = (name, value) => options({ [name]: value }); + +/** + * Override just headers in fetch. + */ +export const headers = (value) => option('headers', value); + +/** + * Override just a single header in fetch. + */ +export const header = (name, value) => headers({ [name]: value }); + + +/** + * Lift a wrapper function so that it can wrap a function returning more than just a Promise. + * + * For example, a wrapper can be a function that takes a `fetch` function and returns another + * `fetch` function with method defaulting to `POST`. This function allows us to lift the wrapper + * so that it applies on modified `fetch` functions that return an object containing `promise` field + * instead of a single Promise like AbortableFetch. + * + * @sig ((...params → Promise) → (...params → Promise)) → (...params → {promise: Promise, ...}) → (...params → {promise: Promise, ...}) + */ +export const liftToPromiseField = (wrapper) => (f) => (...params) => { + let rest; + let promise = wrapper((...innerParams) => { + let {promise, ...innerRest} = f(...innerParams); + rest = innerRest; + return promise; + })(...params); + + return {promise, ...rest}; +}; + + +/** + * Wrapper for fetch that makes it cancellable using AbortController. + * @return {controller: AbortController, promise: Promise} + */ +export const makeAbortableFetch = (fetch) => (url, opts = {}) => { + let controller = new AbortController(); + let promise = fetch(url, { + signal: controller.signal, + ...opts + }); + + return {controller, promise}; +}; + + +/** + * Wrapper for abortable fetch that adds timeout support. + * @return {controller: AbortController, promise: Promise} + */ +export const makeFetchWithTimeout = (abortableFetch) => (url, opts = {}) => { + // offline db consistency requires ajax calls to fail reliably, + // so we enforce a default timeout on ajax calls + let { timeout = 60000, ...rest } = opts; + let {controller, promise} = abortableFetch(url, rest); + + if (timeout !== 0) { + let newPromise = promise.catch((error) => { + // Change error name in case of time out so that we can + // distinguish it from explicit abort. + if (error.name === 'AbortError' && promise.timedOut) { + error = new TimeoutError(`Request timed out after ${timeout / 1000} seconds`); + } + + throw error; + }); + + setTimeout(() => { + promise.timedOut = true; + controller.abort(); + }, timeout); + + return {controller, promise: newPromise}; + } + + return {controller, promise}; +}; + + +/** + * Wrapper for fetch that makes it fail on HTTP errors. + * @return Promise + */ +export const makeFetchFailOnHttpErrors = (fetch) => (url, opts = {}) => { + let { failOnHttpErrors = true, ...rest } = opts; + let promise = fetch(url, rest); + + if (failOnHttpErrors) { + return promise.then(rejectIfNotOkay); + } + + return promise; +}; + + +/** + * Wrapper for fetch that converts URLSearchParams body of GET requests to query string. + */ +export const makeFetchSupportGetBody = (fetch) => (url, opts = {}) => { + let { body, method, ...rest } = opts; + + let newUrl = url; + let newOpts = opts; + if (Object.keys(opts).includes('method') && Object.keys(opts).includes('body') && method.toUpperCase() === 'GET' && body instanceof URLSearchParams) { + let [main, ...fragments] = newUrl.split('#'); + let separator = main.includes('?') ? '&' : '?'; + // append the body to the query string + newUrl = `${main}${separator}${body.toString()}#${fragments.join('#')}`; + // remove the body since it has been moved to URL + newOpts = { method, rest }; + } + + return fetch(newUrl, newOpts); +}; + + +/** + * Cancellable fetch with timeout support that rejects on HTTP errors. + * In such case, the `response` will be member of the Error object. + * @return {controller: AbortController, promise: Promise} + */ +export const fetch = pipe( + // Same as jQuery.ajax + option('credentials', 'same-origin'), + header('X-Requested-With', 'XMLHttpRequest'), + + makeFetchFailOnHttpErrors, + makeFetchSupportGetBody, + makeAbortableFetch, + makeFetchWithTimeout +)(window.fetch); + + +export const get = liftToPromiseField(option('method', 'GET'))(fetch); + + +export const post = liftToPromiseField(option('method', 'POST'))(fetch); + + +/** + * Using URLSearchParams directly handles dictionaries inconveniently. + * For example, it joins arrays with commas or includes undefined keys. + */ +export const makeSearchParams = (data) => new URLSearchParams(formurlencoded(data)); diff --git a/assets/js/selfoss-base.js b/assets/js/selfoss-base.js index dda6b39cf9..100742b7d8 100644 --- a/assets/js/selfoss-base.js +++ b/assets/js/selfoss-base.js @@ -1,4 +1,5 @@ import templates from './templates'; +import * as ajax from './helpers/ajax'; /** * base javascript application @@ -48,37 +49,33 @@ var selfoss = { // We will try to obtain a new configuration anyway } - $.get({ - url: 'api/about', - dataType: 'json', + ajax.get('api/about', { // we want fresh configuration each time - cache: false, - success: function({configuration}) { - localStorage.setItem('configuration', JSON.stringify(configuration)); - - if (oldConfiguration && 'caches' in window) { - if (oldConfiguration.userCss !== configuration.userCss) { - caches.delete('userCss').then(() => - caches.open('userCss').then(cache => cache.add(`user.css?v=${configuration.userCss}`)) - ); - } - if (oldConfiguration.userJs !== configuration.userJs) { - caches.delete('userJs').then(() => - caches.open('userJs').then(cache => cache.add(`user.js?v=${configuration.userJs}`)) - ); - } + cache: 'no-store' + }).promise.then(response => response.json()).then(({configuration}) => { + localStorage.setItem('configuration', JSON.stringify(configuration)); + + if (oldConfiguration && 'caches' in window) { + if (oldConfiguration.userCss !== configuration.userCss) { + caches.delete('userCss').then(() => + caches.open('userCss').then(cache => cache.add(`user.css?v=${configuration.userCss}`)) + ); + } + if (oldConfiguration.userJs !== configuration.userJs) { + caches.delete('userJs').then(() => + caches.open('userJs').then(cache => cache.add(`user.js?v=${configuration.userJs}`)) + ); } + } - selfoss.initMain(configuration); - }, + selfoss.initMain(configuration); + }).catch(() => { // on failure, we will try to use the last cached config - error: function() { - if (oldConfiguration) { - selfoss.initMain(oldConfiguration); - } else { - // TODO: Add a more proper error page - $('body').html(selfoss.ui._('error_configuration')); - } + if (oldConfiguration) { + selfoss.initMain(oldConfiguration); + } else { + // TODO: Add a more proper error page + $('body').html(selfoss.ui._('error_configuration')); } }); }, @@ -110,10 +107,6 @@ var selfoss = { ); } - // offline db consistency requires ajax calls to fail reliably, - // so we enforce a default timeout on ajax calls - jQuery.ajaxSetup({timeout: 60000 }); - $(function() { document.body.classList.toggle('publicupdate', configuration.allowPublicUpdate); document.body.classList.toggle('publicmode', configuration.publicMode); @@ -231,33 +224,28 @@ var selfoss = { selfoss.db.clear(); } - var f = $('#loginform form'); - $.ajax({ - type: 'POST', - url: 'login', - dataType: 'json', - data: f.serialize(), - success: function(data) { - if (data.success) { - $('#password').val(''); - selfoss.setSession(); - selfoss.ui.login(); - selfoss.ui.showMainUi(); - selfoss.initUi(); - if (selfoss.db.storage || !selfoss.db.enableOffline) { - selfoss.db.reloadList(); - } else { - selfoss.dbOffline.init().catch(selfoss.events.init); - } - selfoss.events.initHash(); + var f = document.querySelector('#loginform form'); + ajax.post('login', { + body: new URLSearchParams(new FormData(f)) + }).promise.then(response => response.json()).then((data) => { + if (data.success) { + $('#password').val(''); + selfoss.setSession(); + selfoss.ui.login(); + selfoss.ui.showMainUi(); + selfoss.initUi(); + if (selfoss.db.storage || !selfoss.db.enableOffline) { + selfoss.db.reloadList(); } else { - selfoss.events.setHash('login', false); - selfoss.ui.showLogin(data.error); + selfoss.dbOffline.init().catch(selfoss.events.init); } - }, - complete: function() { - $('#loginform').removeClass('loading'); + selfoss.events.initHash(); + } else { + selfoss.events.setHash('login', false); + selfoss.ui.showLogin(data.error); } + }).finally(() => { + $('#loginform').removeClass('loading'); }); e.preventDefault(); }, @@ -270,14 +258,8 @@ var selfoss = { selfoss.events.setHash('login', false); } - $.ajax({ - type: 'GET', - url: 'logout', - dataType: 'json', - error: function(jqXHR, textStatus, errorThrown) { - selfoss.ui.showError(selfoss.ui._('error_logout') + ' ' + - textStatus + ' ' + errorThrown); - } + ajax.get('logout').promise.catch((error) => { + selfoss.ui.showError(selfoss.ui._('error_logout') + ' ' + error.message); }); }, @@ -395,19 +377,12 @@ var selfoss = { reloadTags: function() { $('#nav-tags').addClass('loading'); - $.ajax({ - url: 'tags', - type: 'GET', - success: function(data) { - selfoss.refreshTags(data); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.ui.showError(selfoss.ui._('error_load_tags') + ' ' + - textStatus + ' ' + errorThrown); - }, - complete: function() { - $('#nav-tags').removeClass('loading'); - } + ajax.get('tags').promise.then(response => response.json()).then((data) => { + selfoss.refreshTags(data); + }).catch((error) => { + selfoss.ui.showError(selfoss.ui._('error_load_tags') + ' ' + error.message); + }).finally(() => { + $('#nav-tags').removeClass('loading'); }); }, @@ -603,32 +578,28 @@ var selfoss = { selfoss.dbOffline.entriesMark(ids, false).then(displayNextUnread); } - $.ajax({ - url: 'mark', - type: 'POST', - dataType: 'json', - contentType: 'application/json; charset=utf-8', - data: JSON.stringify(ids), - success: function() { - selfoss.db.setOnline(); - displayNextUnread(); + ajax.post('mark', { + headers: { + 'content-type': 'application/json; charset=utf-8' }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.handleAjaxError(jqXHR.status).then(function() { - let statuses = ids.map(id => ({ - entryId: id, - name: 'unread', - value: false - })); - selfoss.dbOffline.enqueueStatuses(statuses); - }, function() { - content.html(articleList); - selfoss.ui.refreshStreamButtons(true, hadMore); - selfoss.ui.listReady(); - selfoss.ui.showError(selfoss.ui._('error_mark_items') + - ' ' + textStatus + ' ' + errorThrown); - }); - } + body: JSON.stringify(ids) + }).promise.then(response => response.json()).then(function() { + selfoss.db.setOnline(); + displayNextUnread(); + }).catch(function(error) { + selfoss.handleAjaxError(error?.response?.status || 0).then(function() { + let statuses = ids.map(id => ({ + entryId: id, + name: 'unread', + value: false + })); + selfoss.dbOffline.enqueueStatuses(statuses); + }).catch(function() { + content.html(articleList); + selfoss.ui.refreshStreamButtons(true, hadMore); + selfoss.ui.listReady(); + selfoss.ui.showError(selfoss.ui._('error_mark_items') + ' ' + error.message); + }); }); }, @@ -637,13 +608,11 @@ var selfoss = { if (tryOffline && httpCode != 403) { return selfoss.db.setOffline(); } else { - var handled = $.Deferred(); - handled.reject(); if (httpCode == 403) { selfoss.ui.logout(); selfoss.ui.showLogin(selfoss.ui._('error_session_expired')); } - return handled; + return Promise.reject(); } }, @@ -683,8 +652,11 @@ var selfoss = { }); }); selfoss.logout(); - } + }, + + // Include helpers for user scripts. + ajax }; diff --git a/assets/js/selfoss-db.js b/assets/js/selfoss-db.js index 3802013934..8ccdb9c2d0 100644 --- a/assets/js/selfoss-db.js +++ b/assets/js/selfoss-db.js @@ -10,6 +10,8 @@ */ import selfoss from './selfoss-base'; +import { OfflineStorageNotAvailableError } from './errors'; +import * as ajax from './helpers/ajax'; import Dexie from 'dexie'; selfoss.dbOnline = { @@ -58,10 +60,10 @@ selfoss.dbOnline = { if (success) { selfoss.dbOnline.syncing.resolve(); } else { - var request = selfoss.dbOnline.syncing.request; + let request = selfoss.dbOnline.syncing.request; selfoss.dbOnline.syncing.reject(); if (request) { - request.abort(); + request.controller.abort(); } } } @@ -113,122 +115,121 @@ selfoss.dbOnline = { selfoss.dbOnline.statsDirty = false; - syncing.request = $.ajax({ - url: 'items/sync', - type: updatedStatuses ? 'POST' : 'GET', - dataType: 'json', - data: syncParams, - success: function(data) { - selfoss.db.setOnline(); - - selfoss.db.lastSync = Date.now(); - selfoss.dbOnline.firstSync = false; - - var dataDate = new Date(data.lastUpdate); + syncing.request = ajax.fetch('items/sync', { + method: updatedStatuses ? 'POST' : 'GET', + body: ajax.makeSearchParams(syncParams) + }); - var storing = false; + syncing.request.promise.then(response => response.json()).then((data) => { + selfoss.db.setOnline(); - if (selfoss.db.storage) { - if ('newItems' in data) { - var maxId = 0; - data.newItems.forEach(function(item) { - item.datetime = new Date(item.datetime); - maxId = Math.max(item.id, maxId); - }); + selfoss.db.lastSync = Date.now(); + selfoss.dbOnline.firstSync = false; - selfoss.dbOffline.newerEntriesMissing = 'lastId' in data - && data.lastId > selfoss.dbOffline.lastItemId - && data.lastId > maxId; - storing = selfoss.dbOffline.newerEntriesMissing; + var dataDate = new Date(data.lastUpdate); - selfoss.dbOffline - .shouldLoadEntriesOnline = 'lastId' in data - && data.lastId - selfoss.dbOffline.lastItemId > - 2 * selfoss.filter.itemsPerPage; + var storing = false; - selfoss.dbOffline.storeEntries(data.newItems) - .then(function() { - selfoss.dbOffline.storeLastUpdate(dataDate); + if (selfoss.db.storage) { + if ('newItems' in data) { + var maxId = 0; + data.newItems.forEach(function(item) { + item.datetime = new Date(item.datetime); + maxId = Math.max(item.id, maxId); + }); - selfoss.dbOnline._syncDone(); + selfoss.dbOffline.newerEntriesMissing = 'lastId' in data + && data.lastId > selfoss.dbOffline.lastItemId + && data.lastId > maxId; + storing = selfoss.dbOffline.newerEntriesMissing; - // fetch more if server has more - if (selfoss.dbOffline.newerEntriesMissing) { - selfoss.dbOnline.sync(); - } - }); - } + selfoss.dbOffline + .shouldLoadEntriesOnline = 'lastId' in data + && data.lastId - selfoss.dbOffline.lastItemId > + 2 * selfoss.filter.itemsPerPage; - if ('itemUpdates' in data) { - // refresh entry statuses in db and dequeue queued - // statuses but do not calculate stats as they are taken - // directly from the server as provided. - selfoss.dbOffline - .storeEntryStatuses(data.itemUpdates, true, false) - .then(function() { - selfoss.dbOffline.storeLastUpdate(dataDate); - }); - } + selfoss.dbOffline.storeEntries(data.newItems) + .then(function() { + selfoss.dbOffline.storeLastUpdate(dataDate); + selfoss.dbOnline._syncDone(); + }); + } - if ('stats' in data) { - selfoss.dbOffline.storeStats(data.stats); - } + if (selfoss.dbOffline.newerEntriesMissing + || selfoss.dbOffline.needsSync) { + // There are still new items to fetch + // or statuses to send + syncing.then(function() { + selfoss.dbOffline.sendNewStatuses(); + }); } - if (!selfoss.dbOnline.statsDirty && 'stats' in data) { - selfoss.refreshStats(data.stats.total, - data.stats.unread, - data.stats.starred); + if ('itemUpdates' in data) { + // refresh entry statuses in db and dequeue queued + // statuses but do not calculate stats as they are taken + // directly from the server as provided. + selfoss.dbOffline + .storeEntryStatuses(data.itemUpdates, true, false) + .then(function() { + selfoss.dbOffline.storeLastUpdate(dataDate); + }); } - if ('tags' in data) { - selfoss.refreshTags(data.tags); + if ('stats' in data) { + selfoss.dbOffline.storeStats(data.stats); } + } + + if (!selfoss.dbOnline.statsDirty && 'stats' in data) { + selfoss.refreshStats(data.stats.total, + data.stats.unread, + data.stats.starred); + } + + if ('tags' in data) { + selfoss.refreshTags(data.tags); + } - if ('sources' in data) { - selfoss.refreshSources(data.sources); + if ('sources' in data) { + selfoss.refreshSources(data.sources); + } + + if ('stats' in data && data.stats.unread > 0 && + ($('.stream-empty').is(':visible') || + $('.stream-error').is(':visible'))) { + selfoss.db.reloadList(); + } else { + if ('itemUpdates' in data) { + selfoss.ui.refreshEntryStatuses(data.itemUpdates); } - if ('stats' in data && data.stats.unread > 0 && - ($('.stream-empty').is(':visible') || - $('.stream-error').is(':visible'))) { - selfoss.db.reloadList(); - } else { - if ('itemUpdates' in data) { - selfoss.ui.refreshEntryStatuses(data.itemUpdates); + if (selfoss.filter.type == 'unread') { + var unreadCount = 0; + if ('stats' in data) { + unreadCount = data.stats.unread; + } else { + unreadCount = parseInt($('.unread-count .count') + .html()); } - - if (selfoss.filter.type == 'unread') { - var unreadCount = 0; - if ('stats' in data) { - unreadCount = data.stats.unread; - } else { - unreadCount = parseInt($('.unread-count .count') - .html()); - } - if (unreadCount > $('.entry.unread').length) { - $('.stream-more').show(); - } + if (unreadCount > $('.entry.unread').length) { + $('.stream-more').show(); } } + } - selfoss.db.lastUpdate = dataDate; + selfoss.db.lastUpdate = dataDate; - if (!storing) { - selfoss.dbOnline._syncDone(); - } - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.dbOnline._syncDone(false); - selfoss.handleAjaxError(jqXHR.status).fail(function() { - selfoss.ui.showError(selfoss.ui._('error_sync') + ' ' + - textStatus + ' ' + errorThrown); - }); - }, - complete: function() { - if (selfoss.dbOnline.syncing) { - selfoss.dbOnline.syncing.request = null; - } + if (!storing) { + selfoss.dbOnline._syncDone(); + } + }).catch(function(error) { + selfoss.dbOnline._syncDone(false); + selfoss.handleAjaxError(error?.response?.status || 0).catch(function() { + selfoss.ui.showError(selfoss.ui._('error_sync') + ' ' + error.message); + }); + }).finally(function() { + if (selfoss.dbOnline.syncing) { + selfoss.dbOnline.syncing.request = null; } }); @@ -243,57 +244,52 @@ selfoss.dbOnline = { */ reloadList: function() { if (selfoss.activeAjaxReq !== null) { - selfoss.activeAjaxReq.abort(); + selfoss.activeAjaxReq.controller.abort(); } - selfoss.activeAjaxReq = $.ajax({ - url: '', - type: 'GET', - dataType: 'json', - data: selfoss.filter, - success: function(data) { - selfoss.db.setOnline(); - - if (!selfoss.db.storage) { - selfoss.db.lastSync = Date.now(); - selfoss.db.lastUpdate = new Date(data.lastUpdate); - } + selfoss.activeAjaxReq = ajax.get('', { + body: ajax.makeSearchParams(selfoss.filter) + }); + + let promise = selfoss.activeAjaxReq.promise.then(response => response.json()).then((data) => { + selfoss.db.setOnline(); - selfoss.refreshStats(data.all, data.unread, data.starred); + if (!selfoss.db.storage) { + selfoss.db.lastSync = Date.now(); + selfoss.db.lastUpdate = new Date(data.lastUpdate); + } - $('#content').append(data.entries); - selfoss.ui.refreshStreamButtons(true, data.hasMore); + selfoss.refreshStats(data.all, data.unread, data.starred); - // update tags - selfoss.refreshTags(data.tags); + $('#content').append(data.entries); + selfoss.ui.refreshStreamButtons(true, data.hasMore); - if (selfoss.filter.sourcesNav) { - selfoss.refreshSources(data.sources); - } - }, - error: function(jqXHR, textStatus, errorThrown) { - if (textStatus == 'abort') { - return; - } + // update tags + selfoss.refreshTags(data.tags); - selfoss.handleAjaxError(jqXHR.status).then(function() { - selfoss.dbOffline.reloadList(); - selfoss.ui.afterReloadList(); - }, function() { - selfoss.ui.showError(selfoss.ui._('error_loading') + - ' ' + textStatus + ' ' + errorThrown); - selfoss.events.entries(); - selfoss.ui.refreshStreamButtons(); - $('.stream-error').show(); - }); - }, - complete: function() { - // clean up - selfoss.activeAjaxReq = null; + if (selfoss.filter.sourcesNav) { + selfoss.refreshSources(data.sources); } + }).catch((error) => { + if (error.name == 'AbortError') { + return; + } + + selfoss.handleAjaxError(error?.response?.status || 0).then(function() { + selfoss.dbOffline.reloadList(); + selfoss.ui.afterReloadList(); + }).catch(() => { + selfoss.ui.showError(selfoss.ui._('error_loading') + ' ' + error.message); + selfoss.events.entries(); + selfoss.ui.refreshStreamButtons(); + $('.stream-error').show(); + }); + }).finally(() => { + // clean up + selfoss.activeAjaxReq = null; }); - return selfoss.activeAjaxReq; + return promise; } @@ -335,12 +331,7 @@ selfoss.dbOffline = { init: function() { if (!selfoss.db.enableOffline) { - var d = $.Deferred(); - d.catch = function(fn) { - d.then(null, fn); - }; - d.reject(); - return d; + return Promise.reject(); } selfoss.db.storage = new Dexie('selfoss'); @@ -401,7 +392,16 @@ selfoss.dbOffline = { selfoss.db.tryOnline(); }); $(window).bind('offline', function() { - selfoss.db.setOffline(); + selfoss.db.setOffline().catch((error) => { + if (error instanceof OfflineStorageNotAvailableError) { + selfoss.ui.showError(selfoss.ui._('error_offline_storage_not_available', [ + '', + '' + ])); + } else { + throw error; + } + }); }); selfoss.ui.setOnline(); @@ -410,7 +410,7 @@ selfoss.dbOffline = { .then(function() { selfoss.reloadTags(); }) - .always(selfoss.events.init); + .finally(selfoss.events.init); selfoss.dbOffline.reloadOnlineStats(); selfoss.dbOffline.refreshStats(); }).catch(function() { @@ -584,7 +584,6 @@ selfoss.dbOffline = { } } - if (!ascOrder && !alwaysInDb && entry.datetime < selfoss.dbOffline.newestGCedEntry) { // the offline db is missing older entries, the next @@ -828,17 +827,16 @@ selfoss.db = { setOffline: function() { - var d = $.Deferred(); - if (selfoss.db.storage) { selfoss.dbOnline._syncDone(false); - d.resolve(); selfoss.db.online = false; selfoss.ui.setOffline(); + + return Promise.resolve(); } else { - d.reject(); + let err = new OfflineStorageNotAvailableError(); + return Promise.reject(err); } - return d; }, @@ -850,9 +848,7 @@ selfoss.db = { selfoss.db.lastUpdate = null; return clearing; } else { - var d = $.Deferred(); - d.resolve(); - return d; + return Promise.resolve(); } }, diff --git a/assets/js/selfoss-events-entries.js b/assets/js/selfoss-events-entries.js index 041cff67fe..ab441dfb01 100644 --- a/assets/js/selfoss-events-entries.js +++ b/assets/js/selfoss-events-entries.js @@ -1,5 +1,6 @@ import createFocusTrap from 'focus-trap'; import selfoss from './selfoss-base'; +import * as ajax from './helpers/ajax'; /** * initialize events for entries @@ -203,25 +204,20 @@ selfoss.events.entries = function() { var articleList = content.html(); $('#content').addClass('loading').html(''); - $.ajax({ - url: 'source/' + selfoss.filter.source + '/update', - type: 'POST', - dataType: 'text', - data: {}, - success: function() { - // hide nav on smartphone - if (selfoss.isSmartphone()) { - $('#nav-mobile-settings').click(); - } - // refresh list - selfoss.db.reloadList(); - }, - error: function(jqXHR, textStatus, errorThrown) { - content.html(articleList); - $('#content').removeClass('loading'); - alert(selfoss.ui._('error_refreshing_source') + ' ' + errorThrown); - }, + ajax.post('source/' + selfoss.filter.source + '/update', { timeout: 0 + }).promise.then(() => { + // hide nav on smartphone + if (selfoss.isSmartphone()) { + $('#nav-mobile-settings').click(); + } + + // refresh list + selfoss.db.reloadList(); + }).catch((error) => { + content.html(articleList); + $('#content').removeClass('loading'); + alert(selfoss.ui._('error_refreshing_source') + ' ' + error.message); }); }); diff --git a/assets/js/selfoss-events-entriestoolbar.js b/assets/js/selfoss-events-entriestoolbar.js index 2ad0b39c20..df8dabaef1 100644 --- a/assets/js/selfoss-events-entriestoolbar.js +++ b/assets/js/selfoss-events-entriestoolbar.js @@ -1,4 +1,5 @@ import selfoss from './selfoss-base'; +import * as ajax from './helpers/ajax'; /** * toolbar of an single entry @@ -107,24 +108,17 @@ selfoss.events.entriesToolbar = function(parent) { selfoss.dbOffline.entryStar(id, starr); } - $.ajax({ - url: (starr ? 'starr/' : 'unstarr/') + id, - data: {}, - type: 'POST', - success: function() { - selfoss.db.setOnline(); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.handleAjaxError(jqXHR.status).then(function() { - selfoss.dbOffline.enqueueStatus(id, 'starred', starr); - }, function() { - // rollback ui changes - selfoss.ui.entryStar(id, !starr); - updateStats(!starr); - selfoss.ui.showError(selfoss.ui._('error_star_item') + ' ' + - textStatus + ' ' + errorThrown); - }); - } + ajax.post(`${starr ? 'starr' : 'unstarr'}/${id}`).promise.then(() => { + selfoss.db.setOnline(); + }).catch((error) => { + selfoss.handleAjaxError(error?.response?.status || 0).then(function() { + selfoss.dbOffline.enqueueStatus(id, 'starred', starr); + }).catch(function() { + // rollback ui changes + selfoss.ui.entryStar(id, !starr); + updateStats(!starr); + selfoss.ui.showError(selfoss.ui._('error_star_item') + ' ' + error.message); + }); }); return false; @@ -162,24 +156,17 @@ selfoss.events.entriesToolbar = function(parent) { selfoss.dbOffline.entryMark(id, !unread); } - $.ajax({ - url: (unread ? 'mark/' : 'unmark/') + id, - data: {}, - type: 'POST', - success: function() { - selfoss.db.setOnline(); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.handleAjaxError(jqXHR.status).then(function() { - selfoss.dbOffline.enqueueStatus(id, 'unread', !unread); - }, function() { - // rollback ui changes - selfoss.ui.entryMark(id, unread); - updateStats(!unread); - selfoss.ui.showError(selfoss.ui._('error_mark_item') + ' ' + - textStatus + ' ' + errorThrown); - }); - } + ajax.post(`${unread ? 'mark' : 'unmark'}/${id}`).promise.then(() => { + selfoss.db.setOnline(); + }).catch((error) => { + selfoss.handleAjaxError(error?.response?.status || 0).then(function() { + selfoss.dbOffline.enqueueStatus(id, 'unread', !unread); + }).catch(function() { + // rollback ui changes + selfoss.ui.entryMark(id, unread); + updateStats(!unread); + selfoss.ui.showError(selfoss.ui._('error_mark_item') + ' ' + error.message); + }); }); return false; diff --git a/assets/js/selfoss-events-navigation.js b/assets/js/selfoss-events-navigation.js index a8090586a5..bab78c1130 100644 --- a/assets/js/selfoss-events-navigation.js +++ b/assets/js/selfoss-events-navigation.js @@ -1,4 +1,5 @@ import selfoss from './selfoss-base'; +import * as ajax from './helpers/ajax'; /** * initialize navigation events @@ -15,22 +16,17 @@ selfoss.events.navigation = function() { change: function(color) { $(this).css('backgroundColor', color.toHexString()); - $.ajax({ - url: 'tags/color', - type: 'POST', - data: { + ajax.post('tags/color', { + body: ajax.makeSearchParams({ tag: $(this).parent().find('.tag').html(), color: color.toHexString() - }, - success: function() { - selfoss.ui.beforeReloadList(); - selfoss.dbOnline.reloadList(); - selfoss.ui.afterReloadList(); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.ui.showError(selfoss.ui._('error_saving_color') + ' ' + - textStatus + ' ' + errorThrown); - } + }) + }).promise.then(() => { + selfoss.ui.beforeReloadList(); + selfoss.dbOnline.reloadList(); + selfoss.ui.afterReloadList(); + }).catch((error) => { + selfoss.ui.showError(selfoss.ui._('error_saving_color') + ' ' + error.message); }); } @@ -137,16 +133,10 @@ selfoss.events.navigation = function() { selfoss.filter.sourcesNav = $('#nav-sources-title').hasClass('nav-sources-collapsed'); if (selfoss.filter.sourcesNav && !selfoss.sourcesNavLoaded) { - $.ajax({ - url: 'sources/stats', - type: 'GET', - success: function(data) { - selfoss.refreshSources(data); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.ui.showError(selfoss.ui._('error_loading_stats') + ' ' + - textStatus + ' ' + errorThrown); - } + ajax.get('sources/stats').promise.then(response => response.json()).then((data) => { + selfoss.refreshSources(data); + }).catch(function(error) { + selfoss.ui.showError(selfoss.ui._('error_loading_stats') + ' ' + error.message); }); } else { toggle(); @@ -187,38 +177,31 @@ selfoss.events.navigation = function() { $('#nav-refresh').find('svg').addClass('fa-spin'); - $.ajax({ - url: 'update', - type: 'GET', - dataType: 'text', - data: {}, - success: function() { - // hide nav on smartphone - if (selfoss.isSmartphone()) { - $('#nav-mobile-settings').click(); - } + ajax.get('update', { + timeout: 0 + }).promise.then(() => { + // hide nav on smartphone + if (selfoss.isSmartphone()) { + $('#nav-mobile-settings').click(); + } - // probe stats and prompt reload to the user - selfoss.dbOnline.sync().done(function() { - if ($('.unread-count').hasClass('unread')) { - selfoss.ui.showMessage(selfoss.ui._('sources_refreshed'), [ - { - label: selfoss.ui._('reload_list'), - callback() { - $('#nav-filter-unread').click(); - } + // probe stats and prompt reload to the user + selfoss.dbOnline.sync().done(function() { + if ($('.unread-count').hasClass('unread')) { + selfoss.ui.showMessage(selfoss.ui._('sources_refreshed'), [ + { + label: selfoss.ui._('reload_list'), + callback() { + $('#nav-filter-unread').click(); } - ]); - } - }); - }, - error: function(jqXHR, textStatus, errorThrown) { - selfoss.ui.showError(selfoss.ui._('error_refreshing_source') + ' ' + errorThrown); - }, - complete: function() { - $('#nav-refresh').find('svg').removeClass('fa-spin'); - }, - timeout: 0 + } + ]); + } + }); + }).catch((error) => { + selfoss.ui.showError(selfoss.ui._('error_refreshing_source') + ' ' + error.message); + }).finally(() => { + $('#nav-refresh').find('svg').removeClass('fa-spin'); }); }); diff --git a/assets/js/selfoss-events-sources.js b/assets/js/selfoss-events-sources.js index e8d3ba89d2..1c39f7f49f 100644 --- a/assets/js/selfoss-events-sources.js +++ b/assets/js/selfoss-events-sources.js @@ -1,4 +1,5 @@ import selfoss from './selfoss-base'; +import * as ajax from './helpers/ajax'; /** * initialize source editing events for loggedin users @@ -18,18 +19,12 @@ selfoss.events.sources = function() { // add new source $('.source-add').unbind('click').click(function() { - $.ajax({ - url: 'source', - type: 'GET', - success: function(response) { - $('.source-opml').after(response); - selfoss.events.sources(); - }, - error: function(jqXHR, textStatus, errorThrown) { - parent.find('.source-edit-delete').removeClass('loading'); - selfoss.ui.showError(selfoss.ui._('error_add_source') + ' ' + - textStatus + ' ' + errorThrown); - } + ajax.get('source').promise.then(response => response.text()).then((text) => { + $('.source-opml').after(text); + selfoss.events.sources(); + }).catch((error) => { + parent.find('.source-edit-delete').removeClass('loading'); + selfoss.ui.showError(selfoss.ui._('error_add_source') + ' ' + error.message); }); }); @@ -54,47 +49,49 @@ selfoss.events.sources = function() { var values = selfoss.getValues(parent); values['tags'] = values['tags'].split(','); - $.ajax({ - url: url, - type: 'POST', - dataType: 'json', - data: values, - success: function(response) { - var id = response['id']; - parent.attr('data-source-id', id); - - // show saved text - parent.find('.source-showparams').addClass('saved').html(selfoss.ui._('source_saved')); - window.setTimeout(function() { - parent.find('.source-showparams').removeClass('saved').html(selfoss.ui._('source_edit')); - }, 10000); - - // hide input form - parent.find('.source-edit-form').hide(); - - // update title - var title = $('

').html(response.title).text(); - parent.find('.source-title').text(title); - parent.find("input[name='title']").val(title); - - // show all links for new items - parent.removeClass('source-new'); - - // update tags - selfoss.refreshTags(response.tags, true); - - // update sources - selfoss.refreshSources(response.sources, true); - - selfoss.events.navigation(); - }, - error: function(jqXHR) { - selfoss.showErrors(parent, JSON.parse(jqXHR.responseText)); - }, - complete: function() { + ajax.post(url, { + body: ajax.makeSearchParams(values), + failOnHttpErrors: false + }).promise + .then(ajax.rejectUnless(response => response.ok || response.status === 400)) + .then(response => response.json()) + .then((response) => { + if (!response.success) { + selfoss.showErrors(parent, response); + } else { + var id = response['id']; + parent.attr('data-source-id', id); + + // show saved text + parent.find('.source-showparams').addClass('saved').html(selfoss.ui._('source_saved')); + window.setTimeout(function() { + parent.find('.source-showparams').removeClass('saved').html(selfoss.ui._('source_edit')); + }, 10000); + + // hide input form + parent.find('.source-edit-form').hide(); + + // update title + var title = $('

').html(response.title).text(); + parent.find('.source-title').text(title); + parent.find("input[name='title']").val(title); + + // show all links for new items + parent.removeClass('source-new'); + + // update tags + selfoss.refreshTags(response.tags, true); + + // update sources + selfoss.refreshSources(response.sources, true); + + selfoss.events.navigation(); + } + }).catch((error) => { + selfoss.ui.showError(selfoss.ui._('error_edit_source') + ' ' + error.message); + }).finally(() => { parent.find('.source-action').removeClass('loading'); - } - }); + }); }); // delete source @@ -112,23 +109,17 @@ selfoss.events.sources = function() { parent.find('.source-edit-delete').addClass('loading'); // delete on server - $.ajax({ - url: 'source/delete/' + id, - data: {}, - type: 'POST', - success: function() { - parent.fadeOut('fast', function() { - $(this).remove(); - }); - - // reload tags and remove source from navigation - selfoss.reloadTags(); - $(`#nav-sources [data-source-id=${id}]`).parents('li').get(0).remove(); - }, - error: function(jqXHR, textStatus, errorThrown) { - parent.find('.source-edit-delete').removeClass('loading'); - selfoss.ui.showError(selfoss.ui._('error_delete_source') + ' ' + errorThrown); - } + ajax.post(`source/delete/${id}`).promise.then(() => { + parent.fadeOut('fast', function() { + $(this).remove(); + }); + + // reload tags and remove source from navigation + selfoss.reloadTags(); + $(`#nav-sources [data-source-id=${id}]`)?.parents('li')?.get(0)?.remove(); + }).catch((error) => { + parent.find('.source-edit-delete').removeClass('loading'); + selfoss.ui.showError(selfoss.ui._('error_delete_source') + ' ' + error.message); }); }); @@ -151,28 +142,24 @@ selfoss.events.sources = function() { }); params.show(); - if ($.trim(val).length == 0) { + if (val.trim().length === 0) { params.html(''); return; } params.addClass('loading'); - $.ajax({ - url: 'source/params', - data: { spout: val }, - type: 'GET', - success: function(data) { - params.removeClass('loading').html(data); - - // restore param values - params.find('input').each(function(index, param) { - if (savedParamValues[param.name]) { - param.value = savedParamValues[param.name]; - } - }); - }, - error: function(jqXHR, textStatus, errorThrown) { - params.removeClass('loading').append('

  • ' + errorThrown + '
  • '); - } + ajax.get('source/params', { + body: ajax.makeSearchParams({ spout: val }) + }).promise.then(res => res.text()).then((data) => { + params.removeClass('loading').html(data); + + // restore param values + params.find('input').each(function(index, param) { + if (savedParamValues[param.name]) { + param.value = savedParamValues[param.name]; + } + }); + }).catch((error) => { + params.removeClass('loading').append(`
  • ${error.message}
  • `); }); }); }; diff --git a/assets/js/selfoss-events.js b/assets/js/selfoss-events.js index f05329d805..af28172749 100644 --- a/assets/js/selfoss-events.js +++ b/assets/js/selfoss-events.js @@ -1,4 +1,5 @@ import selfoss from './selfoss-base'; +import * as ajax from './helpers/ajax'; selfoss.events = { @@ -190,30 +191,24 @@ selfoss.events = { } if (selfoss.activeAjaxReq !== null) { - selfoss.activeAjaxReq.abort(); + selfoss.activeAjaxReq.controller.abort(); } selfoss.ui.refreshStreamButtons(); $('#content').addClass('loading').html(''); - selfoss.activeAjaxReq = $.ajax({ - url: 'sources', - type: 'GET', - success: function(data) { - $('#content').html(data); - selfoss.events.sources(); - }, - error: function(jqXHR, textStatus, errorThrown) { - if (textStatus == 'abort') { - return; - } - - selfoss.handleAjaxError(jqXHR.status, false).fail(function() { - selfoss.ui.showError(selfoss.ui._('error_loading') + ' ' + - textStatus + ' ' + errorThrown); - }); - }, - complete: function() { - $('#content').removeClass('loading'); + selfoss.activeAjaxReq = ajax.get('sources'); + selfoss.activeAjaxReq.promise.then(res => res.text()).then((data) => { + $('#content').html(data); + selfoss.events.sources(); + }).catch((error) => { + if (error.name === 'AbortError') { + return; } + + selfoss.handleAjaxError(error?.response?.status || 0, false).catch(function() { + selfoss.ui.showError(selfoss.ui._('error_loading') + ' ' + error.message); + }); + }).finally(() => { + $('#content').removeClass('loading'); }); } else if (hash == 'login') { selfoss.ui.showLogin(); diff --git a/assets/js/selfoss-ui.js b/assets/js/selfoss-ui.js index 1842882888..a14637bd5d 100644 --- a/assets/js/selfoss-ui.js +++ b/assets/js/selfoss-ui.js @@ -24,7 +24,7 @@ selfoss.ui = { return; } - document.getElementById('js-loading-message').remove(); + document.getElementById('js-loading-message')?.remove(); initIcons(); diff --git a/assets/locale/en.json b/assets/locale/en.json index 4337046d08..d83035bea5 100644 --- a/assets/locale/en.json +++ b/assets/locale/en.json @@ -77,6 +77,7 @@ "lang_app_reload": "Reload", "lang_error_session_expired": "Session expired", "lang_error_add_source": "Could not add source:", + "lang_error_edit_source": "Could not save source changes:", "lang_error_delete_source": "Could not delete source:", "lang_error_load_tags": "Could not load tags:", "lang_error_unknown_tag": "Unknown tag:", @@ -91,6 +92,7 @@ "lang_error_refreshing_source": "Cannot refresh sources:", "lang_error_sync": "Could not sync last changes from server:", "lang_error_offline_storage": "Offline storage error: {0}. Reloading may help. Disabling offline for now.", + "lang_error_offline_storage_not_available": "Offline storage is not available. Make sure your web browser {0}supports IndexedDB API{1} and, if you are using Google Chrome based browser, that you are not running selfoss in incognito mode.", "lang_error_invalid_subsection": "Invalid subsection:", "lang_error_configuration": "Unable to load configuration.", "lang_error_share_native_abort": "Unable to share item—either there are no share targets, or you cancelled it.", diff --git a/assets/package-lock.json b/assets/package-lock.json index 7392181404..a740ebfc4d 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -4220,6 +4220,11 @@ "mime-types": "^2.1.12" } }, + "form-urlencoded": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/form-urlencoded/-/form-urlencoded-4.2.1.tgz", + "integrity": "sha512-0eFJroOH2qaqc/630d4YZpmsyKmh6sfq/1z3YMXvFab0O6teGnf8640C7gufikwbQJFaC6nPlG4d/GiYVN+Dcw==" + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -8068,6 +8073,11 @@ "through2": "^2.0.0" } }, + "ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/assets/package.json b/assets/package.json index 2c5649f419..05482b47da 100644 --- a/assets/package.json +++ b/assets/package.json @@ -10,9 +10,11 @@ "core-js": "^3.2.1", "dexie": "^2.0.1", "focus-trap": "^5.0.2", + "form-urlencoded": "^4.2.1", "jquery": "^2.2.4", "jquery-focusable": "^1.0.1", "jquery-hotkeys": "^0.2.2", + "ramda": "^0.27.1", "reset-css": "^5.0.1", "spectrum-colorpicker": "^1.8.1" }, From fd5efb44674101531ac1657e6d1b0dd748b1f6af Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Thu, 6 Aug 2020 19:30:41 +0200 Subject: [PATCH 2/8] client: remove more jQuery-ism --- assets/js/selfoss-base.js | 13 ++++++++----- assets/js/selfoss-events-search.js | 6 ++---- assets/js/selfoss-events.js | 7 +++---- assets/js/selfoss-ui.js | 3 +-- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/assets/js/selfoss-base.js b/assets/js/selfoss-base.js index 100742b7d8..82aba0b578 100644 --- a/assets/js/selfoss-base.js +++ b/assets/js/selfoss-base.js @@ -182,10 +182,13 @@ var selfoss = { $(element).find(':input').each(function(i, el) { // get only input elements with name - if ($.trim($(el).attr('name')).length != 0) { - values[$(el).attr('name')] = $(el).val(); - if ($(el).attr('type') == 'checkbox') { - values[$(el).attr('name')] = $(el).attr('checked') ? 1 : 0; + if (el.hasAttribute('name')) { + let name = el.getAttribute('name').trim(); + if (name.length != 0) { + values[name] = $(el).val(); + if ($(el).attr('type') == 'checkbox') { + values[name] = $(el).attr('checked') ? 1 : 0; + } } } }); @@ -273,7 +276,7 @@ var selfoss = { */ showErrors: function(form, errors) { $(form).find('span.error').remove(); - $.each(errors, function(key, val) { + Object.entries(errors).forEach(([key, val]) => { form.find("[name='" + key + "']").addClass('error').parent('li').append('' + val + ''); }); }, diff --git a/assets/js/selfoss-events-search.js b/assets/js/selfoss-events-search.js index b5d848d527..c8498740ee 100644 --- a/assets/js/selfoss-events-search.js +++ b/assets/js/selfoss-events-search.js @@ -33,10 +33,8 @@ selfoss.events.search = function() { var words = splitTerm(term); term = joinTerm(words); $('#search-list').html(''); - var itemId = 0; - $.each(words, function(index, item) { - $('#search-list').append('
  • ' + item + '
  • '); - itemId++; + words.forEach((item, index) => { + $('#search-list').append('
  • ' + item + '
  • '); }); // execute search diff --git a/assets/js/selfoss-events.js b/assets/js/selfoss-events.js index af28172749..b0b0a6d5ce 100644 --- a/assets/js/selfoss-events.js +++ b/assets/js/selfoss-events.js @@ -152,16 +152,15 @@ selfoss.events = { } // load items - if ($.inArray(selfoss.events.section, - ['newest', 'unread', 'starred']) > -1) { + if (['newest', 'unread', 'starred'].includes(selfoss.events.section)) { selfoss.filter.type = selfoss.events.section; selfoss.filter.tag = ''; selfoss.filter.source = ''; if (selfoss.events.subsection) { selfoss.events.lastSubsection = selfoss.events.subsection; - if (selfoss.events.subsection.substr(0, 4) == 'tag-') { + if (selfoss.events.subsection.startsWith('tag-')) { selfoss.filter.tag = selfoss.events.subsection.substr(4); - } else if (selfoss.events.subsection.substr(0, 7) == 'source-') { + } else if (selfoss.events.subsection.startsWith('source-')) { var sourceId = parseInt(selfoss.events.subsection.substr(7)); if (sourceId) { selfoss.filter.source = sourceId; diff --git a/assets/js/selfoss-ui.js b/assets/js/selfoss-ui.js index a14637bd5d..c7da3eba21 100644 --- a/assets/js/selfoss-ui.js +++ b/assets/js/selfoss-ui.js @@ -481,8 +481,7 @@ selfoss.ui = { if (placeholder) { if (state == 'plural') { pluralKeyword = buffer.trim(); - if ($.inArray(pluralKeyword, - ['zero', 'one', 'other']) > -1) { + if (['zero', 'one', 'other'].includes(pluralKeyword)) { buffer = ''; } else { pluralKeyword = undefined; From 39fb04c8003adcc425d20d234e2a2003213eb10c Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Fri, 7 Aug 2020 01:02:52 +0200 Subject: [PATCH 3/8] client: clean up form data extraction --- assets/js/selfoss-base.js | 26 -------------------- assets/js/selfoss-events-sources.js | 38 +++++++++++++++++++---------- src/controllers/Sources/Write.php | 3 +++ src/templates/source.phtml | 10 ++++---- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/assets/js/selfoss-base.js b/assets/js/selfoss-base.js index 82aba0b578..829e43ade2 100644 --- a/assets/js/selfoss-base.js +++ b/assets/js/selfoss-base.js @@ -171,32 +171,6 @@ var selfoss = { }, - /** - * returns an array of name value pairs of all form elements in given element - * - * @return void - * @param element containing the form elements - */ - getValues: function(element) { - var values = {}; - - $(element).find(':input').each(function(i, el) { - // get only input elements with name - if (el.hasAttribute('name')) { - let name = el.getAttribute('name').trim(); - if (name.length != 0) { - values[name] = $(el).val(); - if ($(el).attr('type') == 'checkbox') { - values[name] = $(el).attr('checked') ? 1 : 0; - } - } - } - }); - - return values; - }, - - loggedin: false, diff --git a/assets/js/selfoss-events-sources.js b/assets/js/selfoss-events-sources.js index 1c39f7f49f..d102a44e9d 100644 --- a/assets/js/selfoss-events-sources.js +++ b/assets/js/selfoss-events-sources.js @@ -6,7 +6,9 @@ import * as ajax from './helpers/ajax'; */ selfoss.events.sources = function() { // cancel source editing - $('.source-cancel').unbind('click').click(function() { + $('.source-cancel').unbind('click').click(function(event) { + event.preventDefault(); + var parent = $(this).parents('.source'); if (parent.hasClass('source-new')) { parent.fadeOut('fast', function() { @@ -18,7 +20,9 @@ selfoss.events.sources = function() { }); // add new source - $('.source-add').unbind('click').click(function() { + $('.source-add').unbind('click').click(function(event) { + event.preventDefault(); + ajax.get('source').promise.then(response => response.text()).then((text) => { $('.source-opml').after(text); selfoss.events.sources(); @@ -29,8 +33,10 @@ selfoss.events.sources = function() { }); // save source - $('.source-save').unbind('click').click(function() { - var parent = $(this).parents('.source'); + $('.source-save').unbind('click').click(function(event) { + event.preventDefault(); + + var parent = $(this).parents('form.source'); // remove old errors parent.find('span.error').remove(); @@ -42,15 +48,18 @@ selfoss.events.sources = function() { // get id let id = parent.attr('data-source-id'); - // set url - const url = `source/${id}`; - // get values and params - var values = selfoss.getValues(parent); - values['tags'] = values['tags'].split(','); + var values = new FormData(parent.get(0)); + + // make tags into a list + let oldTags = values.get('tags').split(','); + values.delete('tags'); + oldTags.map(tag => tag.trim()) + .filter(tag => tag !== '') + .forEach(tag => values.append('tags[]', tag)); - ajax.post(url, { - body: ajax.makeSearchParams(values), + ajax.post(`source/${id}`, { + body: new URLSearchParams(values), failOnHttpErrors: false }).promise .then(ajax.rejectUnless(response => response.ok || response.status === 400)) @@ -95,7 +104,9 @@ selfoss.events.sources = function() { }); // delete source - $('.source-delete').unbind('click').click(function() { + $('.source-delete').unbind('click').click(function(event) { + event.preventDefault(); + var answer = confirm(selfoss.ui._('source_warn')); if (answer == false) { return; @@ -124,7 +135,8 @@ selfoss.events.sources = function() { }); // show params - $('.source-showparams').unbind('click').click(function() { + $('.source-showparams').unbind('click').click(function(event) { + event.preventDefault(); $(this).parent().parent().find('.source-edit-form').show(); }); diff --git a/src/controllers/Sources/Write.php b/src/controllers/Sources/Write.php index b87721ca34..38f9c15d75 100644 --- a/src/controllers/Sources/Write.php +++ b/src/controllers/Sources/Write.php @@ -78,6 +78,9 @@ public function write(Base $f3, array $params) { // clean up title and tag data to prevent XSS $title = htmlspecialchars($data['title']); + if (!isset($data['tags'])) { + $data['tags'] = []; + } $tags = array_map('htmlspecialchars', $data['tags']); $spout = $data['spout']; $filter = $data['filter']; diff --git a/src/templates/source.phtml b/src/templates/source.phtml index 79646fbef9..d2b3da1d64 100644 --- a/src/templates/source.phtml +++ b/src/templates/source.phtml @@ -1,5 +1,5 @@ source) ? $this->source['id'] : 'new-' . rand(); ?> -
    source) && isset($this->source['icon']) && $this->source['icon'] != '0') : ?> @@ -9,8 +9,8 @@
    source) ? $this->source['title'] : \F3::get('lang_source_new'); ?>
    -
    +
    @@ -88,9 +88,9 @@
  • - +
  • -
    + From dd8d4285c34308fb5d9283ff18c5148e6d610881 Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Fri, 7 Aug 2020 02:46:07 +0200 Subject: [PATCH 4/8] client: Remove last deferreds --- assets/js/selfoss-db.js | 80 ++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/assets/js/selfoss-db.js b/assets/js/selfoss-db.js index 8ccdb9c2d0..a063755a37 100644 --- a/assets/js/selfoss-db.js +++ b/assets/js/selfoss-db.js @@ -17,46 +17,54 @@ import Dexie from 'dexie'; selfoss.dbOnline = { - syncing: false, + syncing: { + promise: null, + request: null, + resolve: null, + reject: null + }, statsDirty: false, firstSync: true, _syncBegin: function() { - if (!selfoss.dbOnline.syncing) { - selfoss.dbOnline.syncing = $.Deferred(); - selfoss.dbOnline.syncing.always(function() { - selfoss.dbOnline.syncing = false; - selfoss.db.userWaiting = false; - }); - - var monitor = window.setInterval(function() { - var stopChecking = false; - if (selfoss.dbOnline.syncing) { - if (selfoss.db.userWaiting) { - // reject if user has been waiting for more than 10s, - // this means that connectivity is bad: user will get - // local content and server request will continue in - // the background. - selfoss.dbOnline.syncing.reject(); + if (!selfoss.dbOnline.syncing.promise) { + selfoss.dbOnline.syncing.promise = new Promise(function(resolve, reject) { + selfoss.dbOnline.syncing.resolve = resolve; + selfoss.dbOnline.syncing.reject = reject; + var monitor = window.setInterval(function() { + var stopChecking = false; + if (selfoss.dbOnline.syncing.promise) { + if (selfoss.db.userWaiting) { + // reject if user has been waiting for more than 10s, + // this means that connectivity is bad: user will get + // local content and server request will continue in + // the background. + reject(); + stopChecking = true; + } + } else { stopChecking = true; } - } else { - stopChecking = true; - } - if (stopChecking) { - window.clearInterval(monitor); - } - }, 10000); + if (stopChecking) { + window.clearInterval(monitor); + } + }, 10000); + }); + + selfoss.dbOnline.syncing.promise.finally(function() { + selfoss.dbOnline.syncing.promise = null; + selfoss.db.userWaiting = false; + }); } - return selfoss.dbOnline.syncing; + return selfoss.dbOnline.syncing.promise; }, _syncDone: function(success = true) { - if (selfoss.dbOnline.syncing) { + if (selfoss.dbOnline.syncing.promise) { if (success) { selfoss.dbOnline.syncing.resolve(); } else { @@ -76,15 +84,13 @@ selfoss.dbOnline = { * @return Promise */ sync: function(updatedStatuses, chained) { - if (selfoss.dbOnline.syncing && !chained) { + if (selfoss.dbOnline.syncing.promise && !chained) { if (updatedStatuses) { // Ensure the status queue is not cleared and gets sync'ed at // next sync. - var d = $.Deferred(); - d.reject(); - return d; + return Promise.reject(); } else { - return selfoss.dbOnline.syncing; + return selfoss.dbOnline.syncing.promise; } } @@ -115,12 +121,12 @@ selfoss.dbOnline = { selfoss.dbOnline.statsDirty = false; - syncing.request = ajax.fetch('items/sync', { + selfoss.dbOnline.syncing.request = ajax.fetch('items/sync', { method: updatedStatuses ? 'POST' : 'GET', body: ajax.makeSearchParams(syncParams) }); - syncing.request.promise.then(response => response.json()).then((data) => { + selfoss.dbOnline.syncing.request.promise.then(response => response.json()).then((data) => { selfoss.db.setOnline(); selfoss.db.lastSync = Date.now(); @@ -228,7 +234,7 @@ selfoss.dbOnline = { selfoss.ui.showError(selfoss.ui._('error_sync') + ' ' + error.message); }); }).finally(function() { - if (selfoss.dbOnline.syncing) { + if (selfoss.dbOnline.syncing.promise) { selfoss.dbOnline.syncing.request = null; } }); @@ -891,7 +897,7 @@ selfoss.db = { return selfoss.dbOnline.sync(); } } else { - return $.Deferred().resolve(); // ensure any chained function runs + return Promise.resolve(); // ensure any chained function runs } }, @@ -930,9 +936,9 @@ selfoss.db = { }); }; - if (waitForSync && selfoss.dbOnline.syncing) { + if (waitForSync && selfoss.dbOnline.syncing.promise) { selfoss.db.userWaiting = true; - selfoss.dbOnline.syncing.always(reload); + selfoss.dbOnline.syncing.promise.finally(reload); } else { reload(); } From 35de64431c068a6521f0c413bc362613cdeaf09e Mon Sep 17 00:00:00 2001 From: Jan Tojnar Date: Sun, 6 Sep 2020 04:14:10 +0200 Subject: [PATCH 5/8] client: Make _tr return the original promise So that `then` handlers only fire on success and we can attach `catch` handlers to it. --- assets/js/selfoss-db.js | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/assets/js/selfoss-db.js b/assets/js/selfoss-db.js index a063755a37..1a0b0bddad 100644 --- a/assets/js/selfoss-db.js +++ b/assets/js/selfoss-db.js @@ -317,21 +317,23 @@ selfoss.dbOffline = { _tr: function() { - return selfoss.db.storage.transaction - .apply(selfoss.db.storage, arguments) - .catch(function(error) { - selfoss.ui.showError(selfoss.ui._('error_offline_storage', [error.message])); - selfoss.db.storage = null; - selfoss.db.reloadList(); + let promise = selfoss.db.storage.transaction.apply(selfoss.db.storage, arguments); - // If this is a QuotaExceededError, garbage collect more - // entries and hope it helps. - if (error.name === Dexie.errnames.QuotaExceeded) { - selfoss.dbOffline.GCEntries(true); - } + promise.catch(function(error) { + selfoss.ui.showError(selfoss.ui._('error_offline_storage', [error.message])); + selfoss.db.storage = null; + selfoss.db.reloadList(); - throw (error); - }); + // If this is a QuotaExceededError, garbage collect more + // entries and hope it helps. + if (error.name === Dexie.errnames.QuotaExceeded) { + selfoss.dbOffline.GCEntries(true); + } + + throw error; + }); + + return promise; }, From 7048fea4fa66581d3c9cc9f200f68e0084fdea34 Mon Sep 17 00:00:00 2001 From: niol Date: Mon, 7 Sep 2020 14:19:37 +0200 Subject: [PATCH 6/8] sync: Fix date selection --- src/controllers/Items/Sync.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/controllers/Items/Sync.php b/src/controllers/Items/Sync.php index 6490d0bd7b..e40ac23bf1 100644 --- a/src/controllers/Items/Sync.php +++ b/src/controllers/Items/Sync.php @@ -56,11 +56,12 @@ public function sync(Base $f3) { } $since = new \DateTime($params['since']); + $since->setTimeZone(new \DateTimeZone(date_default_timezone_get())); $last_update = new \DateTime($this->itemsDao->lastUpdate()); $sync = [ - 'lastUpdate' => $last_update->format('Y-m-d H:i:s'), + 'lastUpdate' => $last_update->format(\DateTime::ATOM), ]; $sinceId = 0; @@ -72,7 +73,9 @@ public function sync(Base $f3) { $sinceId = $this->itemsDao->lowestIdOfInterest() - 1; // only send 1 day worth of items $notBefore = new \DateTime(); + $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); $notBefore->sub(new \DateInterval('P1D')); + $notBefore->setTimeZone(new \DateTimeZone(date_default_timezone_get())); } $itemsHowMany = $f3->get('items_perpage'); From 3211cd5deffec81dd24bcbf42632c083a5e20331 Mon Sep 17 00:00:00 2001 From: Alexandre Rossi Date: Fri, 11 Sep 2020 14:24:07 +0200 Subject: [PATCH 7/8] fix showing More button when all entries should be fetched online in ascOrder --- assets/js/selfoss-db.js | 16 +++++++++++++--- assets/js/selfoss-ui.js | 6 +++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/assets/js/selfoss-db.js b/assets/js/selfoss-db.js index 1a0b0bddad..c5e14328a2 100644 --- a/assets/js/selfoss-db.js +++ b/assets/js/selfoss-db.js @@ -558,19 +558,29 @@ selfoss.dbOffline = { var isMore = false; var alwaysInDb = selfoss.filter.type === 'starred' || selfoss.filter.type === 'unread'; + var offset = selfoss.filter.offset; entries.filter(function(entry) { + var keepEntry = false; + if (selfoss.filter.extraIds.indexOf(entry.id) > -1) { return true; } if (selfoss.filter.type == 'starred') { - return entry.starred; + keepEntry = entry.starred; } else if (selfoss.filter.type == 'unread') { - return entry.unread; + keepEntry = entry.unread; + } else { + keepEntry = true; + } + + if (keepEntry && offset > 0) { + offset = offset - 1; + return false; } - return true; + return keepEntry; }).until(function(entry) { // stop iteration if enough entries have been shown // go one further to assess if has more diff --git a/assets/js/selfoss-ui.js b/assets/js/selfoss-ui.js index c7da3eba21..3e8aefb313 100644 --- a/assets/js/selfoss-ui.js +++ b/assets/js/selfoss-ui.js @@ -409,9 +409,6 @@ selfoss.ui = { if (selfoss.isSmartphone()) { $('.mark-these-read').show(); } - if (hasMore) { - $('.stream-more').show(); - } } else { $('.stream-empty').show(); if (selfoss.isSmartphone()) { @@ -419,6 +416,9 @@ selfoss.ui = { } } } + if (hasMore) { + $('.stream-more').show(); + } }, From 4ecb2129f1435f32fcfd0cf542505fd3a06b81cc Mon Sep 17 00:00:00 2001 From: Alexandre Rossi Date: Fri, 11 Sep 2020 14:24:42 +0200 Subject: [PATCH 8/8] document how to workaround parcel build/dev switch problems --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db9ddf95fd..515a675381 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Selfoss uses [composer](https://getcomposer.org/) and [npm](https://www.npmjs.co For the client side, you will also need JavaScript dependencies installed by calling `npm install` in the `assets` directory. You can use `npm run install-dependencies` as a shortcut for installing both sets of dependencies. -We use [Parcel](https://parceljs.org/) (installed by the command above) to build the client side of selfoss. Every time anything in `assets` directory changes, you will need to run `npm run build` for the client to be built and installed into the `public` directory. When developing, you can also use `npm run dev`; it will watch for asset changes, rebuild the bundles as needed, and reload selfoss automatically. +We use [Parcel](https://parceljs.org/) (installed by the command above) to build the client side of selfoss. Every time anything in `assets` directory changes, you will need to run `npm run build` for the client to be built and installed into the `public` directory. When developing, you can also use `npm run dev`; it will watch for asset changes, rebuild the bundles as needed, and reload selfoss automatically. Upon switching between `npm run dev` and `npm run build`, you may need to delete `assets/.cache`. If you want to create a package with all the dependencies bundled, you can run `npm run dist` command to produce a zipball.