remotestorage-widget
Version:
remoteStorage.js connect widget
565 lines (496 loc) • 17.9 kB
JavaScript
import widgetHtml from './assets/widget.html';
import widgetCss from './assets/styles.css';
import circleOpenSvg from './assets/circle-open.svg';
/**
* RemoteStorage connect widget
* @constructor
*
* @param {object} remoteStorage - remoteStorage instance
* @param {object} options - Widget options
* @param {boolean} options.leaveOpen - Do not minimize widget when user clicks outside of it (default: false)
* @param {number} options.autoCloseAfter - Time after which the widget closes automatically in ms (default: 1500)
* @param {boolean} options.skipInitial - Don't show the initial connect hint, but show sign-in screen directly instead (default: false)
* @param {boolean} options.logging - Enable logging (default: false)
* @param {boolean,string} options.modalBackdrop - Show a dark, transparent backdrop when opening the widget for connecting an account. (default 'onlySmallScreens')
*/
class Widget {
constructor (remoteStorage, options={}) {
this.rs = remoteStorage;
this.leaveOpen = options.leaveOpen ? options.leaveOpen : false;
this.autoCloseAfter = options.autoCloseAfter ? options.autoCloseAfter : 1500;
this.skipInitial = options.skipInitial ? options.skipInitial : false;
this.logging = options.logging ? options.logging : false;
this.parentContainerEl = null;
if (options.hasOwnProperty('modalBackdrop')) {
if (typeof options.modalBackdrop !== 'boolean' && options.modalBackdrop !== 'onlySmallScreens') {
throw 'options.modalBackdrop has to be true/false or "onlySmallScreens"'
}
this.modalBackdrop = options.modalBackdrop;
} else {
this.modalBackdrop = 'onlySmallScreens';
}
// true if we have remoteStorage connection's info
this.active = false;
// remoteStorage is connected!
this.online = false;
// widget is minimized ?
this.closed = false;
this.lastSynced = null;
this.lastSyncedUpdateLoop = null;
}
log (...msg) {
if (this.logging) {
console.debug('[RS-WIDGET] ', ...msg);
}
}
// handle events !
eventHandler (event, msg) {
this.log('EVENT: ', event);
switch (event) {
case 'ready':
this.setState(this.state);
break;
case 'sync-started':
this.handleSyncStarted();
break;
// For backward compatibility with rs.js <= 2.0.0-beta.6
case 'sync-req-done':
this.handleSyncStarted();
break;
case 'sync-done':
if (this.online && !msg.completed) return;
this.syncInProgress = false;
this.rsSyncButton.classList.remove("rs-rotate");
this.updateLastSyncedStatus();
if (!this.closed && this.shouldCloseWhenSyncDone) {
setTimeout(this.close.bind(this), this.autoCloseAfter);
}
break;
case 'disconnected':
this.active = false;
this.setOnline();
this.setBackendClass(); // removes all backend CSS classes
this.open();
this.setInitialState();
break;
case 'connected':
this.active = true;
this.online = true;
if (this.rs.hasFeature('Sync')) {
this.shouldCloseWhenSyncDone = true;
this.rs.on('sync-req-done', msg => this.eventHandler('sync-req-done', msg));
this.rs.on('sync-done', msg => this.eventHandler('sync-done', msg));
} else {
this.rsSyncButton.classList.add('rs-hidden');
setTimeout(this.close.bind(this), this.autoCloseAfter);
}
let connectedUser = this.rs.remote.userAddress;
this.rsConnectedUser.innerHTML = connectedUser;
this.setBackendClass(this.rs.backend);
this.rsConnectedLabel.textContent = 'Connected';
this.setState('connected');
break;
case 'network-offline':
this.setOffline();
break;
case 'network-online':
this.setOnline();
break;
case 'error':
this.setBackendClass(this.rs.backend);
if (msg.name === 'DiscoveryError') {
this.handleDiscoveryError(msg);
} else if (msg.name === 'SyncError') {
this.handleSyncError(msg);
} else if (msg.name === 'Unauthorized') {
this.handleUnauthorized(msg);
} else {
console.debug(`Encountered unhandled error: "${msg}"`);
}
break;
}
}
setState (state) {
if (!state) return;
this.log('Setting state ', state);
let lastSelected = this.parentContainerEl.querySelector('.rs-box.rs-selected');
if (lastSelected) {
lastSelected.classList.remove('rs-selected');
lastSelected.setAttribute('aria-hidden', 'true');
}
let toSelect = this.parentContainerEl.querySelector('.rs-box.rs-box-'+state);
if (toSelect) {
toSelect.classList.add('rs-selected');
toSelect.setAttribute('aria-hidden', 'false');
}
let currentStateClass = this.rsWidget.className.match(/rs-state-\S+/g)[0];
this.rsWidget.classList.remove(currentStateClass);
this.rsWidget.classList.add(`rs-state-${state || this.state}`);
this.state = state;
}
/**
* Set widget to its inital state
*
* @private
*/
setInitialState () {
if (this.skipInitial) {
this.showChooseOrSignIn();
} else {
this.setState('initial');
}
}
/**
* Create the widget element and add styling.
*
* @returns {object} The widget's DOM element
*
* @private
*/
createHtmlTemplate () {
const element = document.createElement('div');
element.id = "remotestorage-widget";
element.innerHTML = widgetHtml;
const style = document.createElement('style');
style.innerHTML = widgetCss;
element.appendChild(style);
return element;
}
/**
* Sets the `rs-modal` class on the widget element.
* Done by default for small screens (max-width 420px).
*
* @private
*/
setModalClass () {
if (this.modalBackdrop) {
if (this.modalBackdrop === 'onlySmallScreens'
&& !this.isSmallScreen()) {
return;
}
this.rsWidget.classList.add('rs-modal');
}
}
/**
* Save all interactive DOM elements as variables for later access.
*
* @throws {Error} If parent container element not found
* @private
*/
setupElements () {
if (!this.parentContainerEl) {
throw new Error("Parent container element not found");
}
this.rsWidget = this.parentContainerEl.querySelector('.rs-widget');
this.rsBackdrop = this.parentContainerEl.querySelector('.remotestorage-widget-modal-backdrop');
this.rsInitial = this.parentContainerEl.querySelector('.rs-box-initial');
this.rsChoose = this.parentContainerEl.querySelector('.rs-box-choose');
this.rsConnected = this.parentContainerEl.querySelector('.rs-box-connected');
this.rsSignIn = this.parentContainerEl.querySelector('.rs-box-sign-in');
this.rsConnectedLabel = this.parentContainerEl.querySelector('.rs-box-connected .rs-sub-headline');
this.rsChooseRemoteStorageButton = this.parentContainerEl.querySelector('button.rs-choose-rs');
this.rsChooseDropboxButton = this.parentContainerEl.querySelector('button.rs-choose-dropbox');
this.rsChooseGoogleDriveButton = this.parentContainerEl.querySelector('button.rs-choose-googledrive');
this.rsErrorBox = this.parentContainerEl.querySelector('.rs-box-error .rs-error-message');
// check if apiKeys is set for Dropbox or Google [googledrive, dropbox]
// to show/hide relative buttons only if needed
if (! this.rs.apiKeys.hasOwnProperty('googledrive')) {
this.rsChooseGoogleDriveButton.parentNode.removeChild(this.rsChooseGoogleDriveButton);
}
if (! this.rs.apiKeys.hasOwnProperty('dropbox')) {
this.rsChooseDropboxButton.parentNode.removeChild(this.rsChooseDropboxButton);
}
this.rsSignInForm = this.parentContainerEl.querySelector('.rs-sign-in-form');
this.rsAddressInput = this.rsSignInForm.querySelector('input[name=rs-user-address]');
this.rsConnectButton = this.parentContainerEl.querySelector('.rs-connect');
this.rsDisconnectButton = this.parentContainerEl.querySelector('.rs-disconnect');
this.rsSyncButton = this.parentContainerEl.querySelector('.rs-sync');
this.rsLogo = this.parentContainerEl.querySelector('.rs-widget-icon');
this.rsErrorReconnectLink = this.parentContainerEl.querySelector('.rs-box-error a.rs-reconnect');
this.rsErrorDisconnectButton = this.parentContainerEl.querySelector('.rs-box-error button.rs-disconnect');
this.rsConnectedUser = this.parentContainerEl.querySelector('.rs-connected-text h1.rs-user');
}
/**
* Setup all event handlers
*
* @private
*/
setupHandlers () {
this.rs.on('connected', () => this.eventHandler('connected'));
this.rs.on('ready', () => this.eventHandler('ready'));
this.rs.on('disconnected', () => this.eventHandler('disconnected'));
this.rs.on('network-online', () => this.eventHandler('network-online'));
this.rs.on('network-offline', () => this.eventHandler('network-offline'));
this.rs.on('error', (error) => this.eventHandler('error', error));
this.setEventListeners();
this.setClickHandlers();
}
/**
* Append widget to the DOM.
*
* If a parentElement is specified, the widget will be appended to that
* element, otherwise it will be appended to the document's body. The parent
* element can be given either as a simple element ID or as a valid HTML
* element.
*
* @param {String,HTMLElement} [parentElement] - Parent element
* @throws {Error} If the element is not found or is of an unknown type.
*/
attach (element) {
const domElement = this.createHtmlTemplate(element);
this.parentContainerEl;
if (element instanceof HTMLElement) {
this.parentContainerEl = element;
} else if (typeof element === "string") {
this.parentContainerEl = document.getElementById(element);
if (!this.parentContainerEl) {
throw new Error("Failed to find target DOM element with id=\"" + element + "\"");
}
} else if (element) {
throw new Error("Unknown element type. Expected instance of HTMLElement or type of string.");
} else {
this.parentContainerEl = document.body;
}
this.parentContainerEl.appendChild(domElement);
this.setupElements();
this.setupHandlers();
this.setInitialState();
this.setModalClass();
}
setEventListeners () {
this.rsSignInForm.addEventListener('submit', (e) => {
e.preventDefault();
let userAddress = this.parentContainerEl.querySelector('input[name=rs-user-address]').value;
this.disableConnectButton();
this.rs.connect(userAddress);
});
}
/**
* Show the screen for choosing a backend if there is more than one backend
* to choose from. Otherwise it directly shows the remoteStorage connect
* screen.
*
* @private
*/
showChooseOrSignIn () {
if (this.rsWidget.classList.contains('rs-modal')) {
this.rsBackdrop.style.display = 'block';
this.rsBackdrop.classList.add('visible');
}
// choose backend only if some providers are declared
if (this.rs.apiKeys && Object.keys(this.rs.apiKeys).length > 0) {
this.setState('choose');
} else {
this.setState('sign-in');
}
}
setClickHandlers () {
// Initial button
this.rsInitial.addEventListener('click', () => this.showChooseOrSignIn() );
// Choose RS button
this.rsChooseRemoteStorageButton.addEventListener('click', () => {
this.setState('sign-in');
this.rsAddressInput.focus();
});
// Choose Dropbox button
this.rsChooseDropboxButton.addEventListener('click', () => this.rs["dropbox"].connect() );
// Choose Google Drive button
this.rsChooseGoogleDriveButton.addEventListener('click', () => this.rs["googledrive"].connect() );
// Disconnect button
this.rsDisconnectButton.addEventListener('click', () => this.rs.disconnect() );
this.rsErrorReconnectLink.addEventListener('click', () => this.rs.reconnect() );
this.rsErrorDisconnectButton.addEventListener('click', () => this.rs.disconnect() );
// Sync button
if (this.rs.hasFeature('Sync')) {
this.rsSyncButton.addEventListener('click', () => {
if (this.rsSyncButton.classList.contains('rs-rotate')) {
this.rs.stopSync();
this.rsSyncButton.classList.remove("rs-rotate");
} else {
this.rsConnectedLabel.textContent = 'Synchronizing';
this.rs.startSync();
this.rsSyncButton.classList.add("rs-rotate");
}
});
}
// Reduce to icon only if connected and clicked outside of widget
document.addEventListener('click', () => this.close() );
// Clicks on the widget stop the above event
this.rsWidget.addEventListener('click', e => e.stopPropagation() );
// Click on the logo to toggle the widget's open/close state
this.rsLogo.addEventListener('click', () => this.toggle() );
}
/**
* Toggle between the widget's open/close state.
*
* When then widget is open and in initial state, it will show the backend
* chooser screen.
*/
toggle () {
if (this.closed) {
this.open();
} else {
if (this.state === 'initial') {
this.showChooseOrSignIn();
} else {
this.close();
}
}
}
/**
* Open the widget.
*/
open () {
this.closed = false;
this.rsWidget.classList.remove('rs-closed');
this.shouldCloseWhenSyncDone = false; // prevent auto-closing when user opened the widget
let selected = this.parentContainerEl.querySelector('.rs-box.rs-selected');
if (selected) {
selected.setAttribute('aria-hidden', 'false');
}
}
/**
* Close the widget to only show the icon.
*
* If the ``leaveOpen`` config is true or there is no storage connected,
* the widget will not close.
*/
close () {
// don't do anything when we have an error
if (this.state === 'error') { return; }
if (!this.leaveOpen && this.active) {
this.closed = true;
this.rsWidget.classList.add('rs-closed');
let selected = this.parentContainerEl.querySelector('.rs-box.rs-selected');
if (selected) {
selected.setAttribute('aria-hidden', 'true');
}
} else if (this.active) {
this.setState('connected');
} else {
this.setInitialState();
}
if (this.rsWidget.classList.contains('rs-modal')) {
this.rsBackdrop.classList.remove('visible');
setTimeout(() => {
this.rsBackdrop.style.display = 'none';
}, 300);
}
}
/**
* Disable the connect button and indicate connect activity
*
* @private
*/
disableConnectButton () {
this.rsConnectButton.disabled = true;
this.rsConnectButton.classList.add('rs-connecting');
const circleSpinner = circleOpenSvg;
this.rsConnectButton.innerHTML = `Connecting ${circleSpinner}`;
}
/**
* (Re)enable the connect button and reset to original state
*
* @private
*/
enableConnectButton () {
this.rsConnectButton.disabled = false;
this.rsConnectButton.textContent = 'Connect';
this.rsConnectButton.classList.remove('rs-connecting');
}
/**
* Mark the widget as offline.
*
* This will not do anything when no account is connected.
*
* @private
*/
setOffline () {
if (this.online) {
this.rsWidget.classList.add('rs-offline');
this.rsConnectedLabel.textContent = 'Offline';
this.online = false;
}
}
/**
* Mark the widget as online.
*
* @private
*/
setOnline () {
if (!this.online) {
this.rsWidget.classList.remove('rs-offline');
if (this.active) {
this.rsConnectedLabel.textContent = 'Connected';
}
}
this.online = true;
}
/**
* Set the remoteStorage backend type to show the appropriate icon.
* If no backend is given, all existing backend CSS classes will be removed.
*
* @param {string} [backend]
*
* @private
*/
setBackendClass (backend) {
this.rsWidget.classList.remove('rs-backend-remotestorage');
this.rsWidget.classList.remove('rs-backend-dropbox');
this.rsWidget.classList.remove('rs-backend-googledrive');
if (backend) {
this.rsWidget.classList.add(`rs-backend-${backend}`);
}
}
showErrorBox (errorMsg) {
this.rsErrorBox.innerHTML = errorMsg;
this.setState('error');
}
hideErrorBox () {
this.rsErrorBox.innerHTML = '';
this.close();
}
handleSyncStarted () {
this.syncInProgress = true;
this.rsSyncButton.classList.add("rs-rotate");
setTimeout(() => {
if (!this.syncInProgress) return;
this.rsConnectedLabel.textContent = 'Synchronizing';
}, 1000);
}
handleDiscoveryError (error) {
let msgContainer = this.parentContainerEl.querySelector('.rs-sign-in-error');
msgContainer.innerHTML = error.message;
msgContainer.classList.remove('rs-hidden');
msgContainer.classList.add('rs-visible');
this.enableConnectButton();
}
handleSyncError (error) {
this.setOffline();
}
handleUnauthorized (error) {
if (error.code && error.code === 'access_denied') {
this.rs.disconnect();
} else {
this.open();
this.showErrorBox(error.message + " ");
this.rsErrorBox.appendChild(this.rsErrorReconnectLink);
this.rsErrorReconnectLink.classList.remove('rs-hidden');
}
}
updateLastSyncedStatus () {
const now = new Date();
if (this.online) {
this.lastSynced = now;
this.rsConnectedLabel.textContent = 'Synced';
} else {
if (!this.rsWidget.classList.contains('rs-state-unauthorized')) {
this.rsConnectedLabel.textContent = 'Offline';
}
}
}
isSmallScreen () {
return window.innerWidth < 421;
}
}
export default Widget;