Fixed google authorization popup

This commit is contained in:
benweet 2017-10-02 01:34:48 +01:00
parent 0a39710dac
commit 73cea1879d
29 changed files with 684 additions and 454 deletions

View File

@ -14,6 +14,10 @@ module.exports = {
plugins: [ plugins: [
'html' 'html'
], ],
globals: {
"NODE_ENV": false,
"VERSION": false
},
// check if imports actually resolve // check if imports actually resolve
'settings': { 'settings': {
'import/resolver': { 'import/resolver': {

View File

@ -1,4 +1,5 @@
var path = require('path') var path = require('path')
var webpack = require('webpack')
var utils = require('./utils') var utils = require('./utils')
var config = require('../config') var config = require('../config')
var vueLoaderConfig = require('./vue-loader.conf') var vueLoaderConfig = require('./vue-loader.conf')
@ -74,6 +75,9 @@ module.exports = {
plugins: [ plugins: [
new StylelintPlugin({ new StylelintPlugin({
files: ['**/*.vue', '**/*.scss'] files: ['**/*.vue', '**/*.scss']
}),
new webpack.DefinePlugin({
VERSION: JSON.stringify(require('../package.json').version)
}) })
] ]
} }

View File

@ -19,7 +19,7 @@ module.exports = merge(baseWebpackConfig, {
devtool: 'source-map', devtool: 'source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': config.dev.env NODE_ENV: config.dev.env.NODE_ENV
}), }),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(), new webpack.HotModuleReplacementPlugin(),

View File

@ -28,7 +28,7 @@ var webpackConfig = merge(baseWebpackConfig, {
plugins: [ plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html // http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': env NODE_ENV: env.NODE_ENV
}), }),
new webpack.optimize.UglifyJsPlugin({ new webpack.optimize.UglifyJsPlugin({
compress: { compress: {

View File

@ -1,10 +1,10 @@
<template> <template>
<div v-if="ready" class="app" :class="{'app--loading': loading}"> <splash-screen v-if="!ready"></splash-screen>
<div v-else class="app" :class="{'app--loading': loading}">
<layout></layout> <layout></layout>
<modal v-if="showModal"></modal> <modal v-if="showModal"></modal>
<notification></notification> <notification></notification>
</div> </div>
<div v-else class="app__spash-screen"></div>
</template> </template>
<script> <script>
@ -13,6 +13,7 @@ import { mapState } from 'vuex';
import Layout from './Layout'; import Layout from './Layout';
import Modal from './Modal'; import Modal from './Modal';
import Notification from './Notification'; import Notification from './Notification';
import SplashScreen from './SplashScreen';
// Global directives // Global directives
Vue.directive('focus', { Vue.directive('focus', {
@ -26,6 +27,7 @@ export default {
Layout, Layout,
Modal, Modal,
Notification, Notification,
SplashScreen,
}, },
computed: { computed: {
...mapState([ ...mapState([

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="layout"> <div class="layout">
<div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar && !styles.showExplorer}"> <div class="layout__panel flex flex--row" :class="{'flex--end': styles.showSideBar}">
<div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :style="{ width: constants.explorerWidth + 'px' }"> <div class="layout__panel layout__panel--explorer" v-show="styles.showExplorer" :style="{ width: constants.explorerWidth + 'px' }">
<explorer></explorer> <explorer></explorer>
</div> </div>

View File

@ -3,6 +3,7 @@
<file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal> <file-properties-modal v-if="config.type === 'fileProperties'"></file-properties-modal>
<settings-modal v-else-if="config.type === 'settings'"></settings-modal> <settings-modal v-else-if="config.type === 'settings'"></settings-modal>
<templates-modal v-else-if="config.type === 'templates'"></templates-modal> <templates-modal v-else-if="config.type === 'templates'"></templates-modal>
<about-modal v-else-if="config.type === 'about'"></about-modal>
<html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal> <html-export-modal v-else-if="config.type === 'htmlExport'"></html-export-modal>
<link-modal v-else-if="config.type === 'link'"></link-modal> <link-modal v-else-if="config.type === 'link'"></link-modal>
<image-modal v-else-if="config.type === 'image'"></image-modal> <image-modal v-else-if="config.type === 'image'"></image-modal>
@ -42,6 +43,7 @@ import editorEngineSvc from '../services/editorEngineSvc';
import FilePropertiesModal from './modals/FilePropertiesModal'; import FilePropertiesModal from './modals/FilePropertiesModal';
import SettingsModal from './modals/SettingsModal'; import SettingsModal from './modals/SettingsModal';
import TemplatesModal from './modals/TemplatesModal'; import TemplatesModal from './modals/TemplatesModal';
import AboutModal from './modals/AboutModal';
import HtmlExportModal from './modals/HtmlExportModal'; import HtmlExportModal from './modals/HtmlExportModal';
import LinkModal from './modals/LinkModal'; import LinkModal from './modals/LinkModal';
import ImageModal from './modals/ImageModal'; import ImageModal from './modals/ImageModal';
@ -69,6 +71,7 @@ export default {
FilePropertiesModal, FilePropertiesModal,
SettingsModal, SettingsModal,
TemplatesModal, TemplatesModal,
AboutModal,
HtmlExportModal, HtmlExportModal,
LinkModal, LinkModal,
ImageModal, ImageModal,

View File

@ -22,13 +22,13 @@
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank"> <a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in syncLocations" :key="location.id" :href="location.url" target="_blank">
<icon-provider :provider-id="location.providerId"></icon-provider> <icon-provider :provider-id="location.providerId"></icon-provider>
</a> </a>
<button class="navigation-bar__button navigation-bar__button--sync button" v-if="isSyncPossible" :disabled="isSyncRequested || offline" @click="requestSync"> <button class="navigation-bar__button navigation-bar__button--sync button" :disabled="!isSyncPossible || isSyncRequested || offline" @click="requestSync">
<icon-sync></icon-sync> <icon-sync></icon-sync>
</button> </button>
<a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank"> <a class="navigation-bar__button navigation-bar__button--location button" :class="{'navigation-bar__button--blink': location.id === currentLocation.id}" v-for="location in publishLocations" :key="location.id" :href="location.url" target="_blank">
<icon-provider :provider-id="location.providerId"></icon-provider> <icon-provider :provider-id="location.providerId"></icon-provider>
</a> </a>
<button class="navigation-bar__button navigation-bar__button--publish button" v-if="publishLocations.length" :disabled="isPublishRequested || offline" @click="requestPublish"> <button class="navigation-bar__button navigation-bar__button--publish button" :disabled="!publishLocations.length || isPublishRequested || offline" @click="requestPublish">
<icon-upload></icon-upload> <icon-upload></icon-upload>
</button> </button>
</div> </div>
@ -148,12 +148,12 @@ export default {
'toggleSideBar', 'toggleSideBar',
]), ]),
requestSync() { requestSync() {
if (!this.isSyncRequested) { if (this.isSyncPossible && !this.isSyncRequested) {
syncSvc.requestSync(); syncSvc.requestSync();
} }
}, },
requestPublish() { requestPublish() {
if (!this.isPublishRequested) { if (this.publishLocations.length && !this.isPublishRequested) {
publishSvc.requestPublish(); publishSvc.requestPublish();
} }
}, },

View File

@ -90,7 +90,7 @@ export default {
height: 100%; height: 100%;
hr { hr {
margin: 10px; margin: 10px 15px;
display: none; display: none;
} }

View File

@ -0,0 +1,22 @@
<template>
<div class="splash-screen">
<div class="splash-screen__inner background-logo"></div>
</div>
</template>
<style lang="scss">
.splash-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 25px;
}
.splash-screen__inner {
margin: 0 auto;
max-width: 600px;
height: 100%;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="user-image" :style="{'background-image': url}">
</div>
</template>
<script>
import googleHelper from '../services/providers/helpers/googleHelper';
const promised = {};
export default {
props: ['userId'],
computed: {
url() {
const userInfo = this.$store.state.userInfo.itemMap[this.userId];
return userInfo && `url('${userInfo.imageUrl}')`;
},
},
created() {
if (!promised[this.userId] && !this.$store.state.offline) {
promised[this.userId] = true;
googleHelper.getUser(this.userId)
.catch(() => {
promised[this.userId] = false;
});
}
},
};
</script>
<style lang="scss">
.user-image {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
</style>

View File

@ -90,75 +90,13 @@ textarea {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
opacity: 0.5; opacity: 0.33;
background-color: transparent; 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 { .textfield {
background-color: transparent; background-color: transparent;
border: 0; border: 0;
@ -289,3 +227,8 @@ textarea {
background-color: transparent; background-color: transparent;
} }
} }
.logo-background {
background: no-repeat center url('../assets/logo.svg');
background-size: contain;
}

View File

@ -5,6 +5,12 @@
<div>Sign in with Google</div> <div>Sign in with Google</div>
<span>Back up and sync all your files, folders and settings.</span> <span>Back up and sync all your files, folders and settings.</span>
</menu-entry> </menu-entry>
<div v-else class="menu-entry flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="loginToken.sub"></user-image>
</div>
<span>Signed in as <b>{{loginToken.name}}</b>.</span>
</div>
<hr> <hr>
<menu-entry @click.native="setPanel('sync')"> <menu-entry @click.native="setPanel('sync')">
<icon-sync slot="icon"></icon-sync> <icon-sync slot="icon"></icon-sync>
@ -49,12 +55,14 @@
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './MenuEntry'; import MenuEntry from './MenuEntry';
import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';
export default { export default {
components: { components: {
MenuEntry, MenuEntry,
UserImage,
}, },
computed: { computed: {
...mapGetters('data', [ ...mapGetters('data', [

View File

@ -10,6 +10,8 @@
</template> </template>
<style lang="scss"> <style lang="scss">
@import '../common/variables.scss';
.menu-entry { .menu-entry {
text-align: left; text-align: left;
padding: 10px; padding: 10px;
@ -30,4 +32,9 @@
margin-right: 12px; margin-right: 12px;
flex: none; flex: none;
} }
.menu-entry__icon--image {
border-radius: $border-radius-base;
overflow: hidden;
}
</style> </style>

View File

@ -16,13 +16,16 @@
<span>Sign out and clean local data.</span> <span>Sign out and clean local data.</span>
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle>
<span>About StackEdit</span>
</menu-entry>
<a href="editor" target="_blank" class="menu-entry button flex flex--row flex--align-center"> <a href="editor" target="_blank" class="menu-entry button flex flex--row flex--align-center">
<div class="menu-entry__icon flex flex--column flex--center"> <div class="menu-entry__icon flex flex--column flex--center">
<icon-alert></icon-alert> <icon-open-in-new></icon-open-in-new>
</div> </div>
<div class="flex flex--column"> <div class="flex flex--column">
<div>StackEdit v4</div> <span>Go back to StackEdit 4</span>
<span>Deprecated.</span>
</div> </div>
</a> </a>
</div> </div>
@ -49,6 +52,9 @@ export default {
return this.$store.dispatch('modal/reset') return this.$store.dispatch('modal/reset')
.then(() => localDbSvc.removeDb()); .then(() => localDbSvc.removeDb());
}, },
about() {
return this.$store.dispatch('modal/open', 'about');
},
}, },
}; };
</script> </script>

View File

@ -0,0 +1,54 @@
<template>
<div class="modal__inner-1 modal__inner-1--about-modal">
<div class="modal__inner-2">
<div class="logo-background"></div>
<div class="app-version">v{{version}} © 2017 Benoit Schweblin</div>
<hr>
<a target="_blank" href="https://github.com/benweet/stackedit/">StackEdit on GitHub</a> /
<a target="_blank" href="https://github.com/benweet/stackedit/issues">issue tracker</a>
<br>
<a target="_blank" href="https://chrome.google.com/webstore/detail/stackedit/iiooodelglhkcpgbajoejffhijaclcdg">Chrome app</a> thanks for your review!
<br>
<a target="_blank" href="https://twitter.com/stackedit/">StackEdit on Twitter</a>
<hr>
<a target="_blank" href="privacy_policy.html">Privacy Policy</a>
<div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
data: () => ({
version: VERSION,
}),
computed: mapGetters('modal', [
'config',
]),
};
</script>
<style lang="scss">
.modal__inner-1--about-modal {
text-align: center;
.logo-background {
height: 75px;
margin-bottom: 0.5rem;
}
.app-version {
font-size: 0.75em;
}
hr {
width: 160px;
max-width: 100%;
margin: 1.5em auto;
}
}
</style>

View File

@ -23,4 +23,67 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '../common/variables.scss';
.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;
}
</style> </style>

View File

@ -1,4 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
import Provider from './Provider';
import FormatBold from './FormatBold'; import FormatBold from './FormatBold';
import FormatItalic from './FormatItalic'; import FormatItalic from './FormatItalic';
import FormatQuoteClose from './FormatQuoteClose'; import FormatQuoteClose from './FormatQuoteClose';
@ -40,9 +41,8 @@ import OpenInNew from './OpenInNew';
import Information from './Information'; import Information from './Information';
import Alert from './Alert'; import Alert from './Alert';
import SignalOff from './SignalOff'; import SignalOff from './SignalOff';
// Providers
import Provider from './Provider';
Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic); Vue.component('iconFormatItalic', FormatItalic);
Vue.component('iconFormatQuoteClose', FormatQuoteClose); Vue.component('iconFormatQuoteClose', FormatQuoteClose);
@ -84,5 +84,3 @@ Vue.component('iconOpenInNew', OpenInNew);
Vue.component('iconInformation', Information); Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert); Vue.component('iconAlert', Alert);
Vue.component('iconSignalOff', SignalOff); Vue.component('iconSignalOff', SignalOff);
// Providers
Vue.component('iconProvider', Provider);

View File

@ -6,7 +6,7 @@ import './icons/';
import App from './components/App'; import App from './components/App';
import store from './store'; import store from './store';
if (process.env.NODE_ENV === 'production') { if (NODE_ENV === 'production') {
OfflinePluginRuntime.install(); OfflinePluginRuntime.install();
} }

266
src/services/networkSvc.js Normal file
View File

@ -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);

View File

@ -1,4 +1,4 @@
import utils from '../../utils'; import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
let Dropbox; let Dropbox;
@ -10,7 +10,7 @@ const getAppKey = (fullAccess) => {
return 'sw0hlixhr8q1xk0'; return 'sw0hlixhr8q1xk0';
}; };
const request = (token, options, args) => utils.request({ const request = (token, options, args) => networkSvc.request({
...options, ...options,
headers: { headers: {
...options.headers || {}, ...options.headers || {},
@ -23,7 +23,7 @@ const request = (token, options, args) => utils.request({
export default { export default {
startOauth2(fullAccess, sub = null, silent = false) { startOauth2(fullAccess, sub = null, silent = false) {
return utils.startOauth2( return networkSvc.startOauth2(
'https://www.dropbox.com/oauth2/authorize', { 'https://www.dropbox.com/oauth2/authorize', {
client_id: getAppKey(fullAccess), client_id: getAppKey(fullAccess),
response_type: 'token', response_type: 'token',
@ -54,7 +54,7 @@ export default {
if (Dropbox) { if (Dropbox) {
return Promise.resolve(); 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(() => { .then(() => {
Dropbox = window.Dropbox; Dropbox = window.Dropbox;
}); });

View File

@ -1,4 +1,5 @@
import utils from '../../utils'; import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
let clientId = 'cbf0cf25cfd026be23e1'; let clientId = 'cbf0cf25cfd026be23e1';
@ -7,7 +8,7 @@ if (utils.origin === 'https://stackedit.io') {
} }
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
const request = (token, options) => utils.request({ const request = (token, options) => networkSvc.request({
...options, ...options,
headers: { headers: {
...options.headers || {}, ...options.headers || {},
@ -21,13 +22,13 @@ const request = (token, options) => utils.request({
export default { export default {
startOauth2(scopes, sub = null, silent = false) { startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2( return networkSvc.startOauth2(
'https://github.com/login/oauth/authorize', { 'https://github.com/login/oauth/authorize', {
client_id: clientId, client_id: clientId,
scope: scopes.join(' '), scope: scopes.join(' '),
}, silent) }, silent)
// Exchange code with token // Exchange code with token
.then(data => utils.request({ .then(data => networkSvc.request({
method: 'GET', method: 'GET',
url: 'oauth2/githubToken', url: 'oauth2/githubToken',
params: { params: {
@ -37,7 +38,7 @@ export default {
}) })
.then(res => res.body)) .then(res => res.body))
// Call the user info endpoint // Call the user info endpoint
.then(accessToken => utils.request({ .then(accessToken => networkSvc.request({
method: 'GET', method: 'GET',
url: 'https://api.github.com/user', url: 'https://api.github.com/user',
params: { params: {

View File

@ -1,7 +1,9 @@
import utils from '../../utils'; import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com'; const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk';
const appsDomain = null; const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h) const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h)
let gapi; let gapi;
@ -17,74 +19,100 @@ const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker']; 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 { 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) { startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2( return networkSvc.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', { 'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId, client_id: clientId,
response_type: 'token', response_type: 'token',
@ -94,13 +122,13 @@ export default {
prompt: silent ? 'none' : null, prompt: silent ? 'none' : null,
}, silent) }, silent)
// Call the token info endpoint // Call the token info endpoint
.then(data => utils.request({ .then(data => networkSvc.request({
method: 'POST', method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo', url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
params: { params: {
access_token: data.accessToken, access_token: data.accessToken,
}, },
}).then((res) => { }, true).then((res) => {
// Check the returned client ID consistency // Check the returned client ID consistency
if (res.body.aud !== clientId) { if (res.body.aud !== clientId) {
throw new Error('Client ID inconsistent.'); throw new Error('Client ID inconsistent.');
@ -125,29 +153,27 @@ export default {
}; };
})) }))
// Call the user info endpoint // Call the user info endpoint
.then(token => request(token, { .then(token => this.getUser(token.sub)
method: 'GET', .then((user) => {
url: 'https://www.googleapis.com/plus/v1/people/me', // Add name to token
}).then((res) => { token.name = user.displayName;
// Add name to token const existingToken = store.getters['data/googleTokens'][token.sub];
token.name = res.body.displayName; if (existingToken) {
const existingToken = store.getters['data/googleTokens'][token.sub]; // We probably retrieved a new token with restricted scopes.
if (existingToken) { // That's no problem, token will be refreshed later with merged scopes.
// We probably retrieved a new token with restricted scopes. // Save flags
// That's no problem, token will be refreshed later with merged scopes. token.isLogin = existingToken.isLogin || token.isLogin;
// Save flags token.isDrive = existingToken.isDrive || token.isDrive;
token.isLogin = existingToken.isLogin || token.isLogin; token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isDrive = existingToken.isDrive || token.isDrive; token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.isBlogger = existingToken.isBlogger || token.isBlogger; token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
token.isPhotos = existingToken.isPhotos || token.isPhotos; // Save nextPageToken
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; token.nextPageToken = existingToken.nextPageToken;
// Save nextPageToken }
token.nextPageToken = existingToken.nextPageToken; // Add token to googleTokens
} store.dispatch('data/setGoogleToken', token);
// Add token to googleTokens return token;
store.dispatch('data/setGoogleToken', token); }));
return token;
}));
}, },
refreshToken(scopes, token) { refreshToken(scopes, token) {
const sub = token.sub; const sub = token.sub;
@ -168,18 +194,22 @@ export default {
// Try to get a new token in background // Try to get a new token in background
return this.startOauth2(mergedScopes, sub, true) return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window // If it fails try to popup a window
.catch(() => utils.checkOnline() // Check that we are online, silent mode is a hack .catch((err) => {
.then(() => store.dispatch('modal/providerRedirection', { if (store.state.offline) {
throw err;
}
return store.dispatch('modal/providerRedirection', {
providerName: 'Google', providerName: 'Google',
onResolve: () => this.startOauth2(mergedScopes, sub), onResolve: () => this.startOauth2(mergedScopes, sub),
}))); });
});
}); });
}, },
loadClientScript() { loadClientScript() {
if (gapi) { if (gapi) {
return Promise.resolve(); 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( .then(() => Promise.all(libraries.map(
library => new Promise((resolve, reject) => window.gapi.load(library, { library => new Promise((resolve, reject) => window.gapi.load(library, {
callback: resolve, callback: resolve,
@ -210,7 +240,7 @@ export default {
}; };
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(driveAppDataScopes, token)
.then((refreshedToken) => { .then((refreshedToken) => {
const getPage = (pageToken = '1') => request(refreshedToken, { const getPage = (pageToken = '1') => this.request(refreshedToken, {
method: 'GET', method: 'GET',
url: 'https://www.googleapis.com/drive/v3/changes', url: 'https://www.googleapis.com/drive/v3/changes',
params: { params: {
@ -233,26 +263,26 @@ export default {
}, },
uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) { uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => uploadFile( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate)); refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
}, },
uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => uploadFile( .then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate)); refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
}, },
downloadFile(token, id) { downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token) return this.refreshToken(getDriveScopes(token), token)
.then(refreshedToken => downloadFile(refreshedToken, id)); .then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
}, },
downloadAppDataFile(token, id) { downloadAppDataFile(token, id) {
return this.refreshToken(driveAppDataScopes, token) return this.refreshToken(driveAppDataScopes, token)
.then(refreshedToken => downloadFile(refreshedToken, id)); .then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
}, },
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) { removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(driveAppDataScopes, token) 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 // 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', method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`, url: `https://www.googleapis.com/drive/v3/files/${id}`,
}))); })));
@ -266,7 +296,7 @@ export default {
if (blogId) { if (blogId) {
return blogId; return blogId;
} }
return request(refreshedToken, { return this.request(refreshedToken, {
url: 'https://www.googleapis.com/blogger/v3/blogs/byurl', url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',
params: { params: {
url: blogUrl, url: blogUrl,
@ -299,7 +329,7 @@ export default {
options.url += postId; options.url += postId;
options.body.id = postId; options.body.id = postId;
} }
return request(refreshedToken, options) return this.request(refreshedToken, options)
.then(res => res.body); .then(res => res.body);
}) })
.then((post) => { .then((post) => {
@ -319,7 +349,7 @@ export default {
options.params.publishDate = published.toISOString(); options.params.publishDate = published.toISOString();
} }
} }
return request(refreshedToken, options) return this.request(refreshedToken, options)
.then(res => res.body); .then(res => res.body);
})); }));
}, },

View File

@ -1,10 +1,10 @@
import utils from '../../utils'; import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
const clientId = '23361'; const clientId = '23361';
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (WordPress tokens expire after 2 weeks) 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, ...options,
headers: { headers: {
...options.headers || {}, ...options.headers || {},
@ -14,7 +14,7 @@ const request = (token, options) => utils.request({
export default { export default {
startOauth2(sub = null, silent = false) { startOauth2(sub = null, silent = false) {
return utils.startOauth2( return networkSvc.startOauth2(
'https://public-api.wordpress.com/oauth2/authorize', { 'https://public-api.wordpress.com/oauth2/authorize', {
client_id: clientId, client_id: clientId,
response_type: 'token', response_type: 'token',

View File

@ -1,7 +1,7 @@
import utils from '../../utils'; import networkSvc from '../../networkSvc';
import store from '../../../store'; import store from '../../../store';
const request = (token, options) => utils.request({ const request = (token, options) => networkSvc.request({
...options, ...options,
headers: { headers: {
...options.headers || {}, ...options.headers || {},
@ -11,7 +11,7 @@ const request = (token, options) => utils.request({
export default { export default {
startOauth2(subdomain, clientId, sub = null, silent = false) { startOauth2(subdomain, clientId, sub = null, silent = false) {
return utils.startOauth2( return networkSvc.startOauth2(
`https://${subdomain}.zendesk.com/oauth/authorizations/new`, { `https://${subdomain}.zendesk.com/oauth/authorizations/new`, {
client_id: clientId, client_id: clientId,
response_type: 'token', response_type: 'token',

View File

@ -35,15 +35,6 @@ window.addEventListener('focus', setLastFocus);
// For addQueryParams() // For addQueryParams()
const urlParser = window.document.createElement('a'); 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 { export default {
workspaceId, workspaceId,
origin, origin,
@ -142,216 +133,4 @@ export default {
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
return urlParser.href; 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);
},
}; };

View File

@ -15,15 +15,17 @@ import explorer from './modules/explorer';
import modal from './modules/modal'; import modal from './modules/modal';
import notification from './modules/notification'; import notification from './modules/notification';
import queue from './modules/queue'; import queue from './modules/queue';
import userInfo from './modules/userInfo';
Vue.use(Vuex); Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production'; const debug = NODE_ENV !== 'production';
const store = new Vuex.Store({ const store = new Vuex.Store({
state: { state: {
ready: false, ready: false,
offline: false, offline: false,
lastOfflineCheck: 0,
}, },
getters: { getters: {
allItemMap: (state) => { allItemMap: (state) => {
@ -39,6 +41,21 @@ const store = new Vuex.Store({
setOffline: (state, value) => { setOffline: (state, value) => {
state.offline = 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: { modules: {
contentState, contentState,
@ -54,37 +71,10 @@ const store = new Vuex.Store({
modal, modal,
notification, notification,
queue, queue,
userInfo,
}, },
strict: debug, strict: debug,
plugins: debug ? [createLogger()] : [], 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; export default store;

View File

@ -16,7 +16,7 @@ const constants = {
explorerWidth: 250, explorerWidth: 250,
sideBarWidth: 280, sideBarWidth: 280,
navigationBarHeight: 44, navigationBarHeight: 44,
buttonBarWidth: 30, buttonBarWidth: 26,
statusBarHeight: 20, statusBarHeight: 20,
}; };
@ -99,15 +99,13 @@ function computeStyles(state, localSettings, getters, styles = {
if (styles.showEditor) { if (styles.showEditor) {
const syncLocations = getters['syncLocation/current']; const syncLocations = getters['syncLocation/current'];
const publishLocations = getters['publishLocation/current']; const publishLocations = getters['publishLocation/current'];
const isSyncPossible = getters['data/loginToken'] || syncLocations.length;
styles.titleMaxWidth = styles.innerWidth - styles.titleMaxWidth = styles.innerWidth -
navigationBarEditButtonsWidth - navigationBarEditButtonsWidth -
navigationBarLeftButtonWidth - navigationBarLeftButtonWidth -
navigationBarRightButtonWidth - navigationBarRightButtonWidth -
navigationBarSpinnerWidth - navigationBarSpinnerWidth -
(navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) - (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) -
(isSyncPossible ? navigationBarSyncPublishButtonsWidth : 0) - (navigationBarSyncPublishButtonsWidth * 2) -
(publishLocations.length ? navigationBarSyncPublishButtonsWidth : 0) -
navigationBarTitleMargin; navigationBarTitleMargin;
if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) { if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) {
styles.hideLocations = true; styles.hideLocations = true;

View File

@ -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);
},
},
};