diff --git a/.eslintrc.js b/.eslintrc.js index 36ae5337..3c6c4baf 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,10 @@ module.exports = { plugins: [ 'html' ], + globals: { + "NODE_ENV": false, + "VERSION": false + }, // check if imports actually resolve 'settings': { 'import/resolver': { diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js index bb70a031..91c09bc1 100644 --- a/build/webpack.base.conf.js +++ b/build/webpack.base.conf.js @@ -1,4 +1,5 @@ var path = require('path') +var webpack = require('webpack') var utils = require('./utils') var config = require('../config') var vueLoaderConfig = require('./vue-loader.conf') @@ -74,6 +75,9 @@ module.exports = { plugins: [ new StylelintPlugin({ files: ['**/*.vue', '**/*.scss'] + }), + new webpack.DefinePlugin({ + VERSION: JSON.stringify(require('../package.json').version) }) ] } diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js index cd92c4a3..6ec2eef5 100644 --- a/build/webpack.dev.conf.js +++ b/build/webpack.dev.conf.js @@ -19,7 +19,7 @@ module.exports = merge(baseWebpackConfig, { devtool: 'source-map', plugins: [ new webpack.DefinePlugin({ - 'process.env': config.dev.env + NODE_ENV: config.dev.env.NODE_ENV }), // https://github.com/glenjamin/webpack-hot-middleware#installation--usage new webpack.HotModuleReplacementPlugin(), diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js index 0c88ed89..27ef7add 100644 --- a/build/webpack.prod.conf.js +++ b/build/webpack.prod.conf.js @@ -28,7 +28,7 @@ var webpackConfig = merge(baseWebpackConfig, { plugins: [ // http://vuejs.github.io/vue-loader/en/workflow/production.html new webpack.DefinePlugin({ - 'process.env': env + NODE_ENV: env.NODE_ENV }), new webpack.optimize.UglifyJsPlugin({ compress: { diff --git a/src/components/App.vue b/src/components/App.vue index 763d7ade..79bd17e4 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,10 +1,10 @@ + + diff --git a/src/components/common/app.scss b/src/components/common/app.scss index 6c7868fe..1d47b712 100644 --- a/src/components/common/app.scss +++ b/src/components/common/app.scss @@ -90,75 +90,13 @@ textarea { &:active, &:focus, &:hover { - opacity: 0.5; + opacity: 0.33; background-color: transparent; - cursor: inherit; + cursor: not-allowed; } } } -.form-entry { - margin: 1em 0; -} - -.form-entry__label { - display: block; - font-size: 0.9rem; - color: #a0a0a0; - - .form-entry--focused & { - color: darken($link-color, 10%); - } -} - -.form-entry__field { - border: 1px solid #d8d8d8; - border-radius: $border-radius-base; - position: relative; - overflow: hidden; - - .form-entry--focused & { - border-color: $link-color; - } -} - -.form-entry__actions { - text-align: right; - margin: 0.25em; -} - -.form-entry__button { - width: 38px; - height: 38px; - padding: 6px; - display: inline-block; - background-color: transparent; - opacity: 0.75; - - &:active, - &:focus, - &:hover { - opacity: 1; - background-color: rgba(0, 0, 0, 0.1); - } -} - -.form-entry__radio, -.form-entry__checkbox { - margin: 0.25em 1em; - - input { - margin-right: 0.25em; - } -} - -.form-entry__info { - font-size: 0.75em; - opacity: 0.5; - line-height: 1.4; - margin: 0.25em 0; -} - .textfield { background-color: transparent; border: 0; @@ -289,3 +227,8 @@ textarea { background-color: transparent; } } + +.logo-background { + background: no-repeat center url('../assets/logo.svg'); + background-size: contain; +} diff --git a/src/components/menus/MainMenu.vue b/src/components/menus/MainMenu.vue index 9e676fd1..822c3373 100644 --- a/src/components/menus/MainMenu.vue +++ b/src/components/menus/MainMenu.vue @@ -5,6 +5,12 @@
Sign in with Google
Back up and sync all your files, folders and settings. +
+
+ +
+ Signed in as {{loginToken.name}}. +

@@ -49,12 +55,14 @@ diff --git a/src/components/modals/AboutModal.vue b/src/components/modals/AboutModal.vue new file mode 100644 index 00000000..0ba4ceed --- /dev/null +++ b/src/components/modals/AboutModal.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/components/modals/FormEntry.vue b/src/components/modals/FormEntry.vue index 390e34ed..9a83c27f 100644 --- a/src/components/modals/FormEntry.vue +++ b/src/components/modals/FormEntry.vue @@ -23,4 +23,67 @@ export default { diff --git a/src/icons/index.js b/src/icons/index.js index 05ac6cb5..4f59dfd0 100644 --- a/src/icons/index.js +++ b/src/icons/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Provider from './Provider'; import FormatBold from './FormatBold'; import FormatItalic from './FormatItalic'; import FormatQuoteClose from './FormatQuoteClose'; @@ -40,9 +41,8 @@ import OpenInNew from './OpenInNew'; import Information from './Information'; import Alert from './Alert'; import SignalOff from './SignalOff'; -// Providers -import Provider from './Provider'; +Vue.component('iconProvider', Provider); Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatItalic', FormatItalic); Vue.component('iconFormatQuoteClose', FormatQuoteClose); @@ -84,5 +84,3 @@ Vue.component('iconOpenInNew', OpenInNew); Vue.component('iconInformation', Information); Vue.component('iconAlert', Alert); Vue.component('iconSignalOff', SignalOff); -// Providers -Vue.component('iconProvider', Provider); diff --git a/src/index.js b/src/index.js index 89c5bbe9..5e07a638 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ import './icons/'; import App from './components/App'; import store from './store'; -if (process.env.NODE_ENV === 'production') { +if (NODE_ENV === 'production') { OfflinePluginRuntime.install(); } diff --git a/src/services/networkSvc.js b/src/services/networkSvc.js new file mode 100644 index 00000000..8aff39b6 --- /dev/null +++ b/src/services/networkSvc.js @@ -0,0 +1,266 @@ +import utils from './utils'; +import store from '../store'; + +const scriptLoadingPromises = Object.create(null); +const oauth2AuthorizationTimeout = 120 * 1000; // 2 minutes +const networkTimeout = 30 * 1000; // 30 sec +let isConnectionDown = false; + +export default { + loadScript(url) { + if (!scriptLoadingPromises[url]) { + scriptLoadingPromises[url] = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.onload = resolve; + script.onerror = () => { + scriptLoadingPromises[url] = null; + reject(); + }; + script.src = url; + document.head.appendChild(script); + }); + } + return scriptLoadingPromises[url]; + }, + startOauth2(url, params = {}, silent = false) { + // Build the authorize URL + const state = utils.uid(); + params.state = state; + params.redirect_uri = utils.oauth2RedirectUri; + const authorizeUrl = utils.addQueryParams(url, params); + + let iframeElt; + let wnd; + if (silent) { + // Use an iframe as wnd for silent mode + iframeElt = document.createElement('iframe'); + iframeElt.style.position = 'absolute'; + iframeElt.style.left = '-9999px'; + iframeElt.src = authorizeUrl; + document.body.appendChild(iframeElt); + wnd = iframeElt.contentWindow; + } else { + // Open a tab otherwise + wnd = window.open(authorizeUrl); + if (!wnd) { + return Promise.reject('The authorize window was blocked.'); + } + } + + return new Promise((resolve, reject) => { + let checkClosedInterval; + let closeTimeout; + let msgHandler; + let clean = () => { + clearInterval(checkClosedInterval); + if (!silent && !wnd.closed) { + wnd.close(); + } + if (iframeElt) { + document.body.removeChild(iframeElt); + } + clearTimeout(closeTimeout); + window.removeEventListener('message', msgHandler); + clean = () => Promise.resolve(); // Prevent from cleaning several times + return Promise.resolve(); + }; + + if (silent) { + iframeElt.onerror = () => clean() + .then(() => reject('Unknown error.')); + closeTimeout = setTimeout( + () => clean() + .then(() => { + isConnectionDown = true; + store.commit('setOffline', true); + store.commit('updateLastOfflineCheck'); + reject('You are offline.'); + }), + networkTimeout); + } else { + closeTimeout = setTimeout( + () => clean() + .then(() => reject('Timeout.')), + oauth2AuthorizationTimeout); + } + + msgHandler = event => event.source === wnd && event.origin === utils.origin && clean() + .then(() => { + const data = {}; + `${event.data}`.slice(1).split('&').forEach((param) => { + const [key, value] = param.split('=').map(decodeURIComponent); + data[key] = value; + }); + if (data.error || data.state !== state) { + reject('Could not get required authorization.'); + } else { + resolve({ + accessToken: data.access_token, + code: data.code, + expiresIn: data.expires_in, + }); + } + }); + + window.addEventListener('message', msgHandler); + if (!silent) { + checkClosedInterval = setInterval(() => wnd.closed && clean() + .then(() => reject('Authorize window was closed.')), 250); + } + }); + }, + request(configParam, offlineCheck = false) { + let retryAfter = 500; // 500 ms + const maxRetryAfter = 30 * 1000; // 30 sec + const config = Object.assign({}, configParam); + config.timeout = config.timeout || networkTimeout; + config.headers = Object.assign({}, config.headers); + if (config.body && typeof config.body === 'object') { + config.body = JSON.stringify(config.body); + config.headers['Content-Type'] = 'application/json'; + } + + function parseHeaders(xhr) { + const pairs = xhr.getAllResponseHeaders().trim().split('\n'); + return pairs.reduce((headers, header) => { + const split = header.trim().split(':'); + const key = split.shift().trim().toLowerCase(); + const value = split.join(':').trim(); + headers[key] = value; + return headers; + }, {}); + } + + function isRetriable(err) { + if (err.status === 403) { + const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason; + return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded'; + } + return err.status === 429 || (err.status >= 500 && err.status < 600); + } + + const attempt = + () => new Promise((resolve, reject) => { + if (offlineCheck) { + store.commit('updateLastOfflineCheck'); + } + const xhr = new window.XMLHttpRequest(); + let timeoutId; + + xhr.onload = () => { + if (offlineCheck) { + store.commit('setOffline', false); + } + clearTimeout(timeoutId); + const result = { + status: xhr.status, + headers: parseHeaders(xhr), + body: xhr.responseText, + }; + if (!config.raw) { + try { + result.body = JSON.parse(result.body); + } catch (e) { + // ignore + } + } + if (result.status >= 200 && result.status < 300) { + resolve(result); + return; + } + reject(result); + }; + + xhr.onerror = () => { + clearTimeout(timeoutId); + if (offlineCheck) { + store.commit('setOffline', true); + reject('You are offline.'); + } else { + reject('Network request failed.'); + } + }; + + timeoutId = setTimeout(() => { + xhr.abort(); + if (offlineCheck) { + store.commit('setOffline', true); + reject('You are offline.'); + } else { + reject('Network request timeout.'); + } + }, config.timeout); + + const url = utils.addQueryParams(config.url, config.params); + xhr.open(config.method || 'GET', url); + Object.keys(config.headers).forEach((key) => { + const value = config.headers[key]; + if (value) { + xhr.setRequestHeader(key, `${value}`); + } + }); + xhr.send(config.body || null); + }) + .catch((err) => { + // Try again later in case of retriable error + if (isRetriable(err) && retryAfter < maxRetryAfter) { + return new Promise( + (resolve) => { + setTimeout(resolve, retryAfter); + // Exponential backoff + retryAfter *= 2; + }) + .then(attempt); + } + throw err; + }); + + return attempt(); + }, +}; + +function checkOffline() { + const isBrowserOffline = window.navigator.onLine === false; + if (!isBrowserOffline && + store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() && + utils.isUserActive() + ) { + store.commit('updateLastOfflineCheck'); + new Promise((resolve, reject) => { + const script = document.createElement('script'); + let timeout; + const cleaner = (cb, res) => () => { + clearTimeout(timeout); + cb(res); + document.head.removeChild(script); + }; + script.onload = cleaner(resolve); + script.onerror = cleaner(reject); + script.src = `https://apis.google.com/js/api.js?${Date.now()}`; + try { + document.head.appendChild(script); // This can fail with bad network + timeout = setTimeout(cleaner(reject), networkTimeout); + } catch (e) { + reject(e); + } + }) + .then(() => { + isConnectionDown = false; + }, () => { + isConnectionDown = true; + }); + } + const offline = isBrowserOffline || isConnectionDown; + if (store.state.offline !== offline) { + store.commit('setOffline', offline); + if (offline) { + store.dispatch('notification/error', 'You are offline.'); + } else { + store.dispatch('notification/info', 'You are back online!'); + } + } +} + +utils.setInterval(checkOffline, 1000); +window.addEventListener('online', checkOffline); +window.addEventListener('offline', checkOffline); diff --git a/src/services/providers/helpers/dropboxHelper.js b/src/services/providers/helpers/dropboxHelper.js index 38dca303..09eba63d 100644 --- a/src/services/providers/helpers/dropboxHelper.js +++ b/src/services/providers/helpers/dropboxHelper.js @@ -1,4 +1,4 @@ -import utils from '../../utils'; +import networkSvc from '../../networkSvc'; import store from '../../../store'; let Dropbox; @@ -10,7 +10,7 @@ const getAppKey = (fullAccess) => { return 'sw0hlixhr8q1xk0'; }; -const request = (token, options, args) => utils.request({ +const request = (token, options, args) => networkSvc.request({ ...options, headers: { ...options.headers || {}, @@ -23,7 +23,7 @@ const request = (token, options, args) => utils.request({ export default { startOauth2(fullAccess, sub = null, silent = false) { - return utils.startOauth2( + return networkSvc.startOauth2( 'https://www.dropbox.com/oauth2/authorize', { client_id: getAppKey(fullAccess), response_type: 'token', @@ -54,7 +54,7 @@ export default { if (Dropbox) { return Promise.resolve(); } - return utils.loadScript('https://www.dropbox.com/static/api/2/dropins.js') + return networkSvc.loadScript('https://www.dropbox.com/static/api/2/dropins.js') .then(() => { Dropbox = window.Dropbox; }); diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js index bb7e4b65..399d182b 100644 --- a/src/services/providers/helpers/githubHelper.js +++ b/src/services/providers/helpers/githubHelper.js @@ -1,4 +1,5 @@ import utils from '../../utils'; +import networkSvc from '../../networkSvc'; import store from '../../../store'; let clientId = 'cbf0cf25cfd026be23e1'; @@ -7,7 +8,7 @@ if (utils.origin === 'https://stackedit.io') { } const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; -const request = (token, options) => utils.request({ +const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, @@ -21,13 +22,13 @@ const request = (token, options) => utils.request({ export default { startOauth2(scopes, sub = null, silent = false) { - return utils.startOauth2( + return networkSvc.startOauth2( 'https://github.com/login/oauth/authorize', { client_id: clientId, scope: scopes.join(' '), }, silent) // Exchange code with token - .then(data => utils.request({ + .then(data => networkSvc.request({ method: 'GET', url: 'oauth2/githubToken', params: { @@ -37,7 +38,7 @@ export default { }) .then(res => res.body)) // Call the user info endpoint - .then(accessToken => utils.request({ + .then(accessToken => networkSvc.request({ method: 'GET', url: 'https://api.github.com/user', params: { diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index fe253ff8..c022242a 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -1,7 +1,9 @@ import utils from '../../utils'; +import networkSvc from '../../networkSvc'; import store from '../../../store'; const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com'; +const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk'; const appsDomain = null; const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h) let gapi; @@ -17,74 +19,100 @@ const photosScopes = ['https://www.googleapis.com/auth/photos']; const libraries = ['picker']; -const request = (token, options) => utils.request({ - ...options, - headers: { - ...options.headers || {}, - Authorization: `Bearer ${token.accessToken}`, - }, -}); - -function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) { - return Promise.resolve() - // Refreshing a token can take a while if an oauth window pops up, so check if it's too late - .then(ifNotTooLate(() => { - const options = { - method: 'POST', - url: 'https://www.googleapis.com/drive/v3/files', - }; - const metadata = { name }; - if (fileId) { - options.method = 'PATCH'; - options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; - } else if (parents) { - // Parents field is not patchable - metadata.parents = parents; - } - if (media) { - const boundary = `-------${utils.uid()}`; - const delimiter = `\r\n--${boundary}\r\n`; - const closeDelimiter = `\r\n--${boundary}--`; - let multipartRequestBody = ''; - multipartRequestBody += delimiter; - multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; - multipartRequestBody += JSON.stringify(metadata); - multipartRequestBody += delimiter; - multipartRequestBody += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`; - multipartRequestBody += media; - multipartRequestBody += closeDelimiter; - options.url = options.url.replace( - 'https://www.googleapis.com/', - 'https://www.googleapis.com/upload/'); - return request(refreshedToken, { - ...options, - params: { - uploadType: 'multipart', - }, - headers: { - 'Content-Type': `multipart/mixed; boundary="${boundary}"`, - }, - body: multipartRequestBody, - }).then(res => res.body); - } - return request(refreshedToken, { - ...options, - body: metadata, - }).then(res => res.body); - })); -} - -function downloadFile(refreshedToken, id) { - return request(refreshedToken, { - method: 'GET', - url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`, - raw: true, - }).then(res => res.body); -} - export default { + request(token, options) { + return networkSvc.request({ + ...options, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${token.accessToken}`, + }, + }, true) + .catch((err) => { + const reason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason; + if (reason === 'authError') { + // Mark the token as revoked and get a new one + store.dispatch('data/setGoogleToken', { + ...token, + expiresOn: 0, + }); + // Refresh token and retry + return this.refreshToken(token.scopes, token) + .then(refreshedToken => this.request(refreshedToken, options)); + } + throw err; + }); + }, + uploadFileInternal(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) { + return Promise.resolve() + // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late + .then(ifNotTooLate(() => { + const options = { + method: 'POST', + url: 'https://www.googleapis.com/drive/v3/files', + }; + const metadata = { name }; + if (fileId) { + options.method = 'PATCH'; + options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; + } else if (parents) { + // Parents field is not patchable + metadata.parents = parents; + } + if (media) { + const boundary = `-------${utils.uid()}`; + const delimiter = `\r\n--${boundary}\r\n`; + const closeDelimiter = `\r\n--${boundary}--`; + let multipartRequestBody = ''; + multipartRequestBody += delimiter; + multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; + multipartRequestBody += JSON.stringify(metadata); + multipartRequestBody += delimiter; + multipartRequestBody += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`; + multipartRequestBody += media; + multipartRequestBody += closeDelimiter; + options.url = options.url.replace( + 'https://www.googleapis.com/', + 'https://www.googleapis.com/upload/'); + return this.request(refreshedToken, { + ...options, + params: { + uploadType: 'multipart', + }, + headers: { + 'Content-Type': `multipart/mixed; boundary="${boundary}"`, + }, + body: multipartRequestBody, + }).then(res => res.body); + } + return this.request(refreshedToken, { + ...options, + body: metadata, + }).then(res => res.body); + })); + }, + downloadFileInternal(refreshedToken, id) { + return this.request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`, + raw: true, + }).then(res => res.body); + }, + getUser(userId) { + return networkSvc.request({ + method: 'GET', + url: `https://www.googleapis.com/plus/v1/people/${userId}?key=${apiKey}`, + }, true) + .then((res) => { + store.commit('userInfo/addItem', { + id: res.body.id, + imageUrl: res.body.image.url, + }); + return res.body; + }); + }, startOauth2(scopes, sub = null, silent = false) { - return utils.startOauth2( + return networkSvc.startOauth2( 'https://accounts.google.com/o/oauth2/v2/auth', { client_id: clientId, response_type: 'token', @@ -94,13 +122,13 @@ export default { prompt: silent ? 'none' : null, }, silent) // Call the token info endpoint - .then(data => utils.request({ + .then(data => networkSvc.request({ method: 'POST', url: 'https://www.googleapis.com/oauth2/v3/tokeninfo', params: { access_token: data.accessToken, }, - }).then((res) => { + }, true).then((res) => { // Check the returned client ID consistency if (res.body.aud !== clientId) { throw new Error('Client ID inconsistent.'); @@ -125,29 +153,27 @@ export default { }; })) // Call the user info endpoint - .then(token => request(token, { - method: 'GET', - url: 'https://www.googleapis.com/plus/v1/people/me', - }).then((res) => { - // Add name to token - token.name = res.body.displayName; - const existingToken = store.getters['data/googleTokens'][token.sub]; - if (existingToken) { - // We probably retrieved a new token with restricted scopes. - // That's no problem, token will be refreshed later with merged scopes. - // Save flags - token.isLogin = existingToken.isLogin || token.isLogin; - token.isDrive = existingToken.isDrive || token.isDrive; - token.isBlogger = existingToken.isBlogger || token.isBlogger; - token.isPhotos = existingToken.isPhotos || token.isPhotos; - token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; - // Save nextPageToken - token.nextPageToken = existingToken.nextPageToken; - } - // Add token to googleTokens - store.dispatch('data/setGoogleToken', token); - return token; - })); + .then(token => this.getUser(token.sub) + .then((user) => { + // Add name to token + token.name = user.displayName; + const existingToken = store.getters['data/googleTokens'][token.sub]; + if (existingToken) { + // We probably retrieved a new token with restricted scopes. + // That's no problem, token will be refreshed later with merged scopes. + // Save flags + token.isLogin = existingToken.isLogin || token.isLogin; + token.isDrive = existingToken.isDrive || token.isDrive; + token.isBlogger = existingToken.isBlogger || token.isBlogger; + token.isPhotos = existingToken.isPhotos || token.isPhotos; + token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; + // Save nextPageToken + token.nextPageToken = existingToken.nextPageToken; + } + // Add token to googleTokens + store.dispatch('data/setGoogleToken', token); + return token; + })); }, refreshToken(scopes, token) { const sub = token.sub; @@ -168,18 +194,22 @@ export default { // Try to get a new token in background return this.startOauth2(mergedScopes, sub, true) // If it fails try to popup a window - .catch(() => utils.checkOnline() // Check that we are online, silent mode is a hack - .then(() => store.dispatch('modal/providerRedirection', { + .catch((err) => { + if (store.state.offline) { + throw err; + } + return store.dispatch('modal/providerRedirection', { providerName: 'Google', onResolve: () => this.startOauth2(mergedScopes, sub), - }))); + }); + }); }); }, loadClientScript() { if (gapi) { return Promise.resolve(); } - return utils.loadScript('https://apis.google.com/js/api.js') + return networkSvc.loadScript('https://apis.google.com/js/api.js') .then(() => Promise.all(libraries.map( library => new Promise((resolve, reject) => window.gapi.load(library, { callback: resolve, @@ -210,7 +240,7 @@ export default { }; return this.refreshToken(driveAppDataScopes, token) .then((refreshedToken) => { - const getPage = (pageToken = '1') => request(refreshedToken, { + const getPage = (pageToken = '1') => this.request(refreshedToken, { method: 'GET', url: 'https://www.googleapis.com/drive/v3/changes', params: { @@ -233,26 +263,26 @@ export default { }, uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) { return this.refreshToken(getDriveScopes(token), token) - .then(refreshedToken => uploadFile( + .then(refreshedToken => this.uploadFileInternal( refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate)); }, uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { return this.refreshToken(driveAppDataScopes, token) - .then(refreshedToken => uploadFile( + .then(refreshedToken => this.uploadFileInternal( refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate)); }, downloadFile(token, id) { return this.refreshToken(getDriveScopes(token), token) - .then(refreshedToken => downloadFile(refreshedToken, id)); + .then(refreshedToken => this.downloadFileInternal(refreshedToken, id)); }, downloadAppDataFile(token, id) { return this.refreshToken(driveAppDataScopes, token) - .then(refreshedToken => downloadFile(refreshedToken, id)); + .then(refreshedToken => this.downloadFileInternal(refreshedToken, id)); }, removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) { return this.refreshToken(driveAppDataScopes, token) // Refreshing a token can take a while if an oauth window pops up, so check if it's too late - .then(ifNotTooLate(refreshedToken => request(refreshedToken, { + .then(ifNotTooLate(refreshedToken => this.request(refreshedToken, { method: 'DELETE', url: `https://www.googleapis.com/drive/v3/files/${id}`, }))); @@ -266,7 +296,7 @@ export default { if (blogId) { return blogId; } - return request(refreshedToken, { + return this.request(refreshedToken, { url: 'https://www.googleapis.com/blogger/v3/blogs/byurl', params: { url: blogUrl, @@ -299,7 +329,7 @@ export default { options.url += postId; options.body.id = postId; } - return request(refreshedToken, options) + return this.request(refreshedToken, options) .then(res => res.body); }) .then((post) => { @@ -319,7 +349,7 @@ export default { options.params.publishDate = published.toISOString(); } } - return request(refreshedToken, options) + return this.request(refreshedToken, options) .then(res => res.body); })); }, diff --git a/src/services/providers/helpers/wordpressHelper.js b/src/services/providers/helpers/wordpressHelper.js index 8196802b..9b04a579 100644 --- a/src/services/providers/helpers/wordpressHelper.js +++ b/src/services/providers/helpers/wordpressHelper.js @@ -1,10 +1,10 @@ -import utils from '../../utils'; +import networkSvc from '../../networkSvc'; import store from '../../../store'; const clientId = '23361'; const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks) -const request = (token, options) => utils.request({ +const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, @@ -14,7 +14,7 @@ const request = (token, options) => utils.request({ export default { startOauth2(sub = null, silent = false) { - return utils.startOauth2( + return networkSvc.startOauth2( 'https://public-api.wordpress.com/oauth2/authorize', { client_id: clientId, response_type: 'token', diff --git a/src/services/providers/helpers/zendeskHelper.js b/src/services/providers/helpers/zendeskHelper.js index 347a2f97..9bb6a6d1 100644 --- a/src/services/providers/helpers/zendeskHelper.js +++ b/src/services/providers/helpers/zendeskHelper.js @@ -1,7 +1,7 @@ -import utils from '../../utils'; +import networkSvc from '../../networkSvc'; import store from '../../../store'; -const request = (token, options) => utils.request({ +const request = (token, options) => networkSvc.request({ ...options, headers: { ...options.headers || {}, @@ -11,7 +11,7 @@ const request = (token, options) => utils.request({ export default { startOauth2(subdomain, clientId, sub = null, silent = false) { - return utils.startOauth2( + return networkSvc.startOauth2( `https://${subdomain}.zendesk.com/oauth/authorizations/new`, { client_id: clientId, response_type: 'token', diff --git a/src/services/utils.js b/src/services/utils.js index 8a0ff9b4..817c78ae 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -35,15 +35,6 @@ window.addEventListener('focus', setLastFocus); // For addQueryParams() const urlParser = window.document.createElement('a'); -// For loadScript() -const scriptLoadingPromises = Object.create(null); - -// For startOauth2 -const oauth2AuthorizationTimeout = 120 * 1000; // 2 minutes - -// For checkOnline -const checkOnlineTimeout = 15 * 1000; // 15 sec - export default { workspaceId, origin, @@ -142,216 +133,4 @@ export default { urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); return urlParser.href; }, - loadScript(url) { - if (!scriptLoadingPromises[url]) { - scriptLoadingPromises[url] = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.onload = resolve; - script.onerror = () => { - scriptLoadingPromises[url] = null; - reject(); - }; - script.src = url; - document.head.appendChild(script); - }); - } - return scriptLoadingPromises[url]; - }, - startOauth2(url, params = {}, silent = false) { - const oauth2Context = {}; - - // Build the authorize URL - const state = this.uid(); - params.state = state; - params.redirect_uri = this.oauth2RedirectUri; - const authorizeUrl = this.addQueryParams(url, params); - if (silent) { - // Use an iframe as wnd for silent mode - oauth2Context.iframeElt = document.createElement('iframe'); - oauth2Context.iframeElt.style.position = 'absolute'; - oauth2Context.iframeElt.style.left = '-9999px'; - oauth2Context.closeTimeout = setTimeout( - () => oauth2Context.clean('Unknown error.'), - checkOnlineTimeout); - oauth2Context.iframeElt.onerror = () => oauth2Context.clean('Unknown error.'); - oauth2Context.iframeElt.src = authorizeUrl; - document.body.appendChild(oauth2Context.iframeElt); - oauth2Context.wnd = oauth2Context.iframeElt.contentWindow; - } else { - // Open a new tab otherwise - oauth2Context.wnd = window.open(authorizeUrl); - // If window opening has been blocked by the browser - if (!oauth2Context.wnd) { - return Promise.reject(); - } - oauth2Context.closeTimeout = setTimeout( - () => oauth2Context.clean('Timeout.'), - oauth2AuthorizationTimeout); - } - return new Promise((resolve, reject) => { - oauth2Context.clean = (errorMsg) => { - clearInterval(oauth2Context.checkClosedInterval); - if (!silent && !oauth2Context.wnd.closed) { - oauth2Context.wnd.close(); - } - if (oauth2Context.iframeElt) { - document.body.removeChild(oauth2Context.iframeElt); - } - clearTimeout(oauth2Context.closeTimeout); - window.removeEventListener('message', oauth2Context.msgHandler); - oauth2Context.clean = () => { - // Prevent from cleaning several times - }; - if (errorMsg) { - reject(new Error(errorMsg)); - } - }; - - oauth2Context.msgHandler = (event) => { - if (event.source === oauth2Context.wnd && - event.origin === this.origin - ) { - oauth2Context.clean(); - const data = {}; - `${event.data}`.slice(1).split('&').forEach((param) => { - const [key, value] = param.split('=').map(decodeURIComponent); - if (key === 'state') { - data.state = value; - } else if (key === 'access_token') { - data.accessToken = value; - } else if (key === 'code') { - data.code = value; - } else if (key === 'expires_in') { - data.expiresIn = value; - } - }); - if (data.state === state) { - resolve(data); - return; - } - reject('Could not get required authorization.'); - } - }; - window.addEventListener('message', oauth2Context.msgHandler); - - oauth2Context.checkClosedInterval = !silent && setInterval(() => { - if (oauth2Context.wnd.closed) { - oauth2Context.clean('Authorize window was closed.'); - } - }, 250); - }); - }, - request(configParam) { - let retryAfter = 500; // 500 ms - const maxRetryAfter = 30 * 1000; // 30 sec - const config = Object.assign({}, configParam); - config.timeout = config.timeout || 30 * 1000; // 30 sec - config.headers = Object.assign({}, config.headers); - if (config.body && typeof config.body === 'object') { - config.body = JSON.stringify(config.body); - config.headers['Content-Type'] = 'application/json'; - } - - function parseHeaders(xhr) { - const pairs = xhr.getAllResponseHeaders().trim().split('\n'); - return pairs.reduce((headers, header) => { - const split = header.trim().split(':'); - const key = split.shift().trim().toLowerCase(); - const value = split.join(':').trim(); - headers[key] = value; - return headers; - }, {}); - } - - function isRetriable(err) { - switch (err.status) { - case 403: - { - const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason; - return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded'; - } - case 429: - return true; - default: - if (err.status >= 500 && err.status < 600) { - return true; - } - } - return false; - } - - const attempt = - () => new Promise((resolve, reject) => { - const xhr = new window.XMLHttpRequest(); - let timeoutId; - - xhr.onload = () => { - clearTimeout(timeoutId); - const result = { - status: xhr.status, - headers: parseHeaders(xhr), - body: xhr.responseText, - }; - if (!config.raw) { - try { - result.body = JSON.parse(result.body); - } catch (e) { - // ignore - } - } - if (result.status >= 200 && result.status < 300) { - resolve(result); - return; - } - reject(result); - }; - - xhr.onerror = () => { - clearTimeout(timeoutId); - reject(new Error('Network request failed.')); - }; - - timeoutId = setTimeout(() => { - xhr.abort(); - reject(new Error('Network request timeout.')); - }, config.timeout); - - const url = this.addQueryParams(config.url, config.params); - xhr.open(config.method || 'GET', url); - Object.keys(config.headers).forEach((key) => { - const value = config.headers[key]; - if (value) { - xhr.setRequestHeader(key, `${value}`); - } - }); - xhr.send(config.body || null); - }) - .catch((err) => { - // Try again later in case of retriable error - if (isRetriable(err) && retryAfter < maxRetryAfter) { - return new Promise( - (resolve) => { - setTimeout(resolve, retryAfter); - // Exponential backoff - retryAfter *= 2; - }) - .then(attempt); - } - throw err; - }); - - return attempt(); - }, - checkOnline() { - const checkStatus = (res) => { - if (!res.status || res.status < 200) { - throw new Error('Offline...'); - } - }; - return this.request({ - url: 'https://www.googleapis.com/plus/v1/people/me', - timeout: checkOnlineTimeout, - }) - .then(checkStatus, checkStatus); - }, }; diff --git a/src/store/index.js b/src/store/index.js index b963b357..64e8714a 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -15,15 +15,17 @@ import explorer from './modules/explorer'; import modal from './modules/modal'; import notification from './modules/notification'; import queue from './modules/queue'; +import userInfo from './modules/userInfo'; Vue.use(Vuex); -const debug = process.env.NODE_ENV !== 'production'; +const debug = NODE_ENV !== 'production'; const store = new Vuex.Store({ state: { ready: false, offline: false, + lastOfflineCheck: 0, }, getters: { allItemMap: (state) => { @@ -39,6 +41,21 @@ const store = new Vuex.Store({ setOffline: (state, value) => { state.offline = value; }, + updateLastOfflineCheck: (state) => { + state.lastOfflineCheck = Date.now(); + }, + }, + actions: { + setOffline: ({ state, commit }, value) => { + if (state.offline !== value) { + commit('setOffline', value); + if (state.offline) { + return Promise.reject('You are offline.'); + } + store.dispatch('notification/info', 'You are back online!'); + } + return Promise.resolve(); + }, }, modules: { contentState, @@ -54,37 +71,10 @@ const store = new Vuex.Store({ modal, notification, queue, + userInfo, }, strict: debug, plugins: debug ? [createLogger()] : [], }); -let isConnectionDown = false; -let lastConnectionCheck = 0; - -function checkOffline() { - const isBrowserOffline = window.navigator.onLine === false; - if (!isBrowserOffline && lastConnectionCheck + 30000 < Date.now() && utils.isUserActive()) { - lastConnectionCheck = Date.now(); - utils.checkOnline() - .then(() => { - isConnectionDown = false; - }, () => { - isConnectionDown = true; - }); - } - const isOffline = isBrowserOffline || isConnectionDown; - if (isOffline !== store.state.offline) { - store.commit('setOffline', isOffline); - if (isOffline) { - store.dispatch('notification/info', 'You are offline.'); - } else { - store.dispatch('notification/info', 'You are back online!'); - } - } -} -utils.setInterval(checkOffline, 1000); -window.addEventListener('online', checkOffline); -window.addEventListener('offline', checkOffline); - export default store; diff --git a/src/store/modules/layout.js b/src/store/modules/layout.js index 61603d61..bae13892 100644 --- a/src/store/modules/layout.js +++ b/src/store/modules/layout.js @@ -16,7 +16,7 @@ const constants = { explorerWidth: 250, sideBarWidth: 280, navigationBarHeight: 44, - buttonBarWidth: 30, + buttonBarWidth: 26, statusBarHeight: 20, }; @@ -99,15 +99,13 @@ function computeStyles(state, localSettings, getters, styles = { if (styles.showEditor) { const syncLocations = getters['syncLocation/current']; const publishLocations = getters['publishLocation/current']; - const isSyncPossible = getters['data/loginToken'] || syncLocations.length; styles.titleMaxWidth = styles.innerWidth - navigationBarEditButtonsWidth - navigationBarLeftButtonWidth - navigationBarRightButtonWidth - navigationBarSpinnerWidth - (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) - - (isSyncPossible ? navigationBarSyncPublishButtonsWidth : 0) - - (publishLocations.length ? navigationBarSyncPublishButtonsWidth : 0) - + (navigationBarSyncPublishButtonsWidth * 2) - navigationBarTitleMargin; if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) { styles.hideLocations = true; diff --git a/src/store/modules/userInfo.js b/src/store/modules/userInfo.js new file mode 100644 index 00000000..adc0d861 --- /dev/null +++ b/src/store/modules/userInfo.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; + +export default { + namespaced: true, + state: { + itemMap: {}, + }, + mutations: { + addItem: (state, item) => { + Vue.set(state.itemMap, item.id, item); + }, + }, +};