diff --git a/app/css/views/microphone.css b/app/css/views/microphone.css index b06232f..709961f 100644 --- a/app/css/views/microphone.css +++ b/app/css/views/microphone.css @@ -1,5 +1,5 @@ .microphone { - position: absolute; + position: fixed; z-index: 1; top: 8rem; right: 9rem; diff --git a/app/index.html b/app/index.html index 0336cec..38b2201 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,7 @@ - + diff --git a/app/js/controllers/main.js b/app/js/controllers/main.js index e61e5d3..b4696ce 100644 --- a/app/js/controllers/main.js +++ b/app/js/controllers/main.js @@ -1,7 +1,9 @@ import BaseController from './base'; import UsersController from './users'; import RemindersController from './reminders'; + import SpeechController from '../lib/speech-controller'; +import Server from '../lib/server/index'; import React from 'components/react'; import ReactDOM from 'components/react-dom'; @@ -9,8 +11,10 @@ import Microphone from '../views/microphone'; const p = Object.freeze({ controllers: Symbol('controllers'), - onHashChanged: Symbol('onHashChanged'), speechController: Symbol('speechController'), + server: Symbol('server'), + + onHashChanged: Symbol('onHashChanged'), }); export default class MainController extends BaseController { @@ -19,7 +23,8 @@ export default class MainController extends BaseController { const mountNode = document.querySelector('.app-view-container'); const speechController = new SpeechController(); - const options = { mountNode, speechController }; + const server = new Server(); + const options = { mountNode, speechController, server }; const usersController = new UsersController(options); const remindersController = new RemindersController(options); @@ -31,6 +36,7 @@ export default class MainController extends BaseController { }; this[p.speechController] = speechController; + this[p.server] = server; window.addEventListener('hashchange', this[p.onHashChanged].bind(this)); } @@ -49,14 +55,19 @@ export default class MainController extends BaseController { }); location.hash = ''; + setTimeout(() => { - //location.hash = 'users/login'; - location.hash = 'reminders'; - }, 16); + if (this[p.server].isLoggedIn) { + location.hash = 'reminders'; + } else { + location.hash = 'users/login'; + } + }); ReactDOM.render( React.createElement(Microphone, { speechController: this[p.speechController], + server: this[p.server], }), document.querySelector('.microphone') ); } diff --git a/app/js/controllers/reminders.js b/app/js/controllers/reminders.js index ada2b4f..87cb15e 100644 --- a/app/js/controllers/reminders.js +++ b/app/js/controllers/reminders.js @@ -9,6 +9,7 @@ export default class RemindersController extends BaseController { ReactDOM.render( React.createElement(Reminders, { speechController: this.speechController, + server: this.server, }), this.mountNode ); } diff --git a/app/js/controllers/users.js b/app/js/controllers/users.js index 0d11936..01010ab 100644 --- a/app/js/controllers/users.js +++ b/app/js/controllers/users.js @@ -29,12 +29,15 @@ export default class UsersController extends BaseController { login() { ReactDOM.render( - React.createElement(UserLogin, {}), this.mountNode + React.createElement(UserLogin, { server: this.server }), this.mountNode ); } logout() { - // Once logged out, we redirect to the login page. - location.hash = '#users/login'; + this.server.logout() + .then(() => { + // Once logged out, we redirect to the login page. + location.hash = 'users/login'; + }); } } diff --git a/app/js/lib/server/api.js b/app/js/lib/server/api.js new file mode 100644 index 0000000..235e93d --- /dev/null +++ b/app/js/lib/server/api.js @@ -0,0 +1,159 @@ +'use strict'; + +const p = Object.freeze({ + settings: Symbol('settings'), + net: Symbol('net'), + + // Private methods. + getURL: Symbol('getURL'), + onceOnline: Symbol('onceOnline'), + onceReady: Symbol('onceReady'), + getChannelValues: Symbol('getChannelValues'), + updateChannelValue: Symbol('updateChannelValue'), +}); + +/** + * Instance of the API class is intended to abstract consumer from the API + * specific details (e.g. API base URL and version). It also tracks + * availability of the network, API host and whether correct user session is + * established. If any of this conditions is not met all API requests are + * blocked until it's possible to perform them, so consumer doesn't have to + * care about these additional checks. + */ +export default class API { + constructor(net, settings) { + this[p.net] = net; + this[p.settings] = settings; + + Object.freeze(this); + } + + /** + * Performs HTTP 'GET' API request and accepts JSON as response. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path. + * @return {Promise} + */ + get(path) { + return this[p.onceReady]() + .then(() => this[p.net].fetchJSON(this[p.getURL](path))); + } + + /** + * Performs HTTP 'POST' API request and accepts JSON as response. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path. + * @param {Object=} body Optional object that will be serialized to JSON + * string and sent as 'POST' body. + * @return {Promise} + */ + post(path, body) { + console.log(path, body); + + return this[p.onceReady]() + .then(() => this[p.net].fetchJSON(this[p.getURL](path), 'POST', body)); + } + + /** + * Performs HTTP 'PUT' API request and accepts JSON as response. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path. + * @param {Object=} body Optional object that will be serialized to JSON + * string and sent as 'PUT' body. + * @return {Promise} + */ + put(path, body) { + return this[p.onceReady]() + .then(() => this[p.net].fetchJSON(this[p.getURL](path), 'PUT', body)); + } + + /** + * Performs HTTP 'DELETE' API request and accepts JSON as response. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path. + * @param {Object=} body Optional object that will be serialized to JSON + * string and sent as 'DELETE' body. + * @return {Promise} + */ + delete(path, body) { + return this[p.onceReady]() + .then(() => this[p.net].fetchJSON(this[p.getURL](path), 'DELETE', body)); + } + + /** + * Performs either HTTP 'GET' or 'PUT' (if body parameter is specified) API + * request and accepts Blob as response. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path. + * @param {Object=} body Optional object that will be serialized to JSON + * string and sent as 'PUT' body. + * @param {string=} accept Mime type of the Blob we expect as a response + * (default is image/jpeg). + * @return {Promise} + */ + blob(path, body, accept = 'image/jpeg') { + return this[p.onceReady]() + .then(() => { + if (body) { + return this[p.net].fetchBlob( + this[p.getURL](path), accept, 'PUT', body + ); + } + + return this[p.net].fetchBlob(this[p.getURL](path), accept); + }); + } + + /** + * Creates a fully qualified API URL based on predefined base origin, API + * version and specified resource path. + * + * @param {string} path Specific API resource path to be used in conjunction + * with the base API path and version. + * @return {string} + * @private + */ + [p.getURL](path) { + if (!path || typeof path !== 'string') { + throw new Error('Path should be a valid non-empty string.'); + } + + return `${this[p.net].origin}/api/v${this[p.settings].apiVersion}/${path}`; + } + + /** + * Returns a promise that is resolved once API is ready to use (API host is + * online). + * In the future we can add more checks like: + * * User is authenticated + * * Document is visible + * + * @returns {Promise} + * @private + */ + [p.onceReady]() { + return Promise.all([ + this[p.onceOnline](), + ]); + } + + /** + * Returns a promise that is resolved once API host is discovered and online. + * + * @returns {Promise} + * @private + */ + [p.onceOnline]() { + const net = this[p.net]; + if (net.online) { + return Promise.resolve(); + } + + return new Promise((resolve) => net.once('online', () => resolve())); + } +} diff --git a/app/js/lib/server/index.js b/app/js/lib/server/index.js new file mode 100644 index 0000000..e1d6f53 --- /dev/null +++ b/app/js/lib/server/index.js @@ -0,0 +1,105 @@ +/* global URLSearchParams */ + +'use strict'; + +import EventDispatcher from '../common/event-dispatcher'; + +import Settings from './settings'; +import Network from './network'; +import WebPush from './webpush'; +import API from './api'; +import Reminders from './reminders'; + +// Private members. +const p = Object.freeze({ + // Private properties. + settings: Symbol('settings'), + net: Symbol('net'), + webPush: Symbol('webPush'), + api: Symbol('api'), +}); + +export default class Server extends EventDispatcher { + constructor({ settings, net } = {}) { + super(['online']); + + // Private properties. + this[p.settings] = settings || new Settings(); + this[p.net] = net || new Network(this[p.settings]); + this[p.api] = new API(this[p.net], this[p.settings]); + this[p.webPush] = new WebPush(this[p.api], this[p.settings]); + + // Init + this.reminders = new Reminders(this[p.api], this[p.settings]); + + this[p.net].on('online', (online) => this.emit('online', online)); + this[p.webPush].on('message', (msg) => this.emit('push-message', msg)); + + window.server = this; + + Object.seal(this); + } + + /** + * Clear all data/settings stored on the browser. Use with caution. + * + * @param {boolean} ignoreServiceWorker + * @return {Promise} + */ + clear(ignoreServiceWorker = true) { + const promises = [this[p.settings].clear()]; + + if (!navigator.serviceWorker && !ignoreServiceWorker) { + promises.push(navigator.serviceWorker.ready + .then((registration) => registration.unregister())); + } + + return Promise.all(promises); + } + + get online() { + return this[p.net].online; + } + + get isLoggedIn() { + return !!this[p.settings].session; + } + + /** + * Authenticate a user. + * + * @param {string} user + * @param {string} password + * @return {Promise} + */ + login(user, password) { + return this[p.api].post('login', { user, password }) + .then((res) => { + this[p.settings].session = res.token; + }); + } + + /** + * Log out the user. + * + * @return {Promise} + */ + logout() { + this[p.settings].session = null; + return Promise.resolve(); + } + + /** + * Ask the user to accept push notifications from the server. + * This method will be called each time that we log in, but will stop the + * execution if we already have the push subscription information. + * + * @param {boolean} resubscribe Parameter used for testing purposes, and + * follow the whole subscription process even if we already have a push + * subscription information. + * @return {Promise} + */ + subscribeToNotifications(resubscribe = false) { + return this[p.webPush].subscribeToNotifications(resubscribe); + } +} diff --git a/app/js/lib/server/network.js b/app/js/lib/server/network.js new file mode 100644 index 0000000..b4be574 --- /dev/null +++ b/app/js/lib/server/network.js @@ -0,0 +1,134 @@ +'use strict'; + +import EventDispatcher from '../common/event-dispatcher'; + +const p = Object.freeze({ + // Private properties. + settings: Symbol('settings'), + online: Symbol('online'), + + // Private methods. + init: Symbol('init'), + fetch: Symbol('fetch'), +}); + +export default class Network extends EventDispatcher { + constructor(settings) { + super(['online']); + + this[p.settings] = settings; + this[p.online] = false; + + Object.seal(this); + + this[p.init](); + } + + /** + * Attach event listeners related to the connection status. + */ + [p.init]() { + this[p.online] = navigator.onLine; + + window.addEventListener('online', (online) => { + this[p.online] = online; + this.emit('online', online); + }); + window.addEventListener('offline', (online) => { + this[p.online] = online; + this.emit('online', online); + }); + + if ('connection' in navigator && 'onchange' in navigator.connection) { + navigator.connection.addEventListener('change', () => { + const online = navigator.onLine; + + this[p.online] = online; + this.emit('online', online); + }); + } + } + + get origin() { + return this[p.settings].origin; + } + + get online() { + return this[p.online]; + } + + /** + * Request a JSON from a specified URL. + * + * @param {string} url The URL to send the request to. + * @param {string} method The HTTP method (defaults to "GET"). + * @param {Object} body An object of key/value. + * @return {Promise} + */ + fetchJSON(url, method = 'GET', body = undefined) { + return this[p.fetch](url, 'application/json', method, body) + .then((response) => response.json()); + } + + /** + * Request a Blob from a specified URL. + * + * @param {string} url The URL to send the request to. + * @param {string} blobType The Blob mime type (eg. image/jpeg). + * @param {string=} method The HTTP method (defaults to "GET"). + * @param {Object=} body An object of key/value. + * @return {Promise} + */ + fetchBlob(url, blobType, method, body) { + return this[p.fetch](url, blobType, method, body) + .then((response) => response.blob()); + } + + /** + * Request a content of the specified type from a specified URL. + * + * @param {string} url The URL to send the request to. + * @param {string} accept The content mime type (eg. image/jpeg). + * @param {string=} method The HTTP method (defaults to "GET"). + * @param {Object=} body An object of key/value. + * @return {Promise} + * @private + */ + [p.fetch](url, accept, method = 'GET', body = undefined) { + method = method.toUpperCase(); + + const req = { + method, + headers: { Accept: accept }, + cache: 'no-store', + }; + + if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + req.headers['Content-Type'] = 'application/json;charset=UTF-8'; + } + + if (this[p.settings].session) { + // The user is logged in, we authenticate the request. + req.headers.Authorization = `Bearer ${this[p.settings].session}`; + } + + if (body !== undefined) { + req.body = JSON.stringify(body); + } + + return fetch(url, req) + .then((res) => { + if (!res.ok) { + throw new TypeError( + `The response returned a ${res.status} HTTP status code.` + ); + } + + return res; + }) + .catch((error) => { + console.error('Error occurred while fetching content: ', error); + throw error; + }); + } +} diff --git a/app/js/lib/server/reminders.js b/app/js/lib/server/reminders.js new file mode 100644 index 0000000..41b2961 --- /dev/null +++ b/app/js/lib/server/reminders.js @@ -0,0 +1,44 @@ +'use strict'; + +const p = Object.freeze({ + api: Symbol('api'), + settings: Symbol('settings'), +}); + +export default class Reminders { + constructor(api, settings) { + this[p.api] = api; + this[p.settings] = settings; + + Object.seal(this); + } + + /** + * Retrieves the list of the reminders. + * + * @return {Promise} A promise that resolves with an array of objects. + */ + getAll() { + return this[p.api].get('reminders'); + } + + /** + * Gets a reminder given its id. + * + * @param {string} id Id of the reminder to retrieve. + * @return {Promise} + */ + get(id) { + return this[p.api].get(`reminders/${id}`); + } + + /** + * Create a new reminder. + * + * @param {Object} body + * @returns {Promise} + */ + set(body) { + return this[p.api].post(`reminders`, body); + } +} diff --git a/app/js/lib/server/settings.js b/app/js/lib/server/settings.js new file mode 100644 index 0000000..a4d8ec6 --- /dev/null +++ b/app/js/lib/server/settings.js @@ -0,0 +1,239 @@ +'use strict'; + +import EventDispatcher from '../common/event-dispatcher'; + +// Prefix all entries to avoid collisions. +const PREFIX = 'cue-'; + +const ORIGIN = 'https://calendar.knilxof.org'; + +/** + * API version to use (currently not configurable). + * @type {number} + * @const + */ +const API_VERSION = 1; + +/** + * Regex to match upper case literals. + * @type {RegExp} + * @const + */ +const UPPER_CASE_REGEX = /([A-Z])/g; + +const p = Object.freeze({ + values: Symbol('values'), + storage: Symbol('storage'), + + // Private methods. + updateSetting: Symbol('updateSetting'), + stringToSettingTypedValue: Symbol('stringToSettingTypedValue'), + getDefaultSettingValue: Symbol('getDefaultSettingValue'), + onStorage: Symbol('onStorage'), +}); + +// Definition of all available settings and their default values (if needed). +const settings = Object.freeze({ + // String settings. + SESSION: Object.freeze({ key: 'session' }), + PUSH_ENDPOINT: Object.freeze({ key: 'pushEndpoint' }), + PUSH_PUB_KEY: Object.freeze({ key: 'pushPubKey' }), + PUSH_AUTH: Object.freeze({ key: 'pushAuth' }), +}); + +export default class Settings extends EventDispatcher { + constructor(storage = localStorage) { + super(); + + // Not all browsers have localStorage supported or activated. + this[p.storage] = storage || { + getItem: () => null, + setItem: () => { + }, + removeItem: () => { + }, + clear: () => { + }, + }; + + this[p.values] = new Map(); + + Object.keys(settings).forEach((settingName) => { + const setting = settings[settingName]; + const settingStringValue = this[p.storage].getItem( + `${PREFIX}${setting.key}` + ); + + // Setting values directly to avoid firing events on startup. + this[p.values].set( + setting, + this[p.stringToSettingTypedValue](setting, settingStringValue) + ); + }); + + window.addEventListener('storage', this[p.onStorage].bind(this)); + + Object.seal(this); + } + + get session() { + return this[p.values].get(settings.SESSION); + } + + set session(value) { + this[p.updateSetting](settings.SESSION, value); + } + + // Getters only. + get origin() { + return ORIGIN; + } + + get apiVersion() { + return API_VERSION; + } + + get pushEndpoint() { + return this[p.values].get(settings.PUSH_ENDPOINT); + } + + set pushEndpoint(value) { + this[p.updateSetting](settings.PUSH_ENDPOINT, value); + } + + get pushPubKey() { + return this[p.values].get(settings.PUSH_PUB_KEY); + } + + set pushPubKey(value) { + this[p.updateSetting](settings.PUSH_PUB_KEY, value); + } + + get pushAuth() { + return this[p.values].get(settings.PUSH_AUTH); + } + + set pushAuth(value) { + this[p.updateSetting](settings.PUSH_AUTH, value); + } + + /** + * Iterates through all known settings and sets default value for all of them. + * + * @return {Promise} + */ + clear() { + return new Promise((resolve) => { + Object.keys(settings).forEach((settingName) => { + const setting = settings[settingName]; + this[p.updateSetting](setting, this[p.getDefaultSettingValue](setting)); + }); + resolve(); + }); + } + + /** + * Tries to update setting with new value. If value has changed corresponding + * event will be emitted. New value is also persisted to the local storage. + * + * @param {Object} setting Setting description object. + * @param {number|boolean|string?} newValue New value for specified setting. + * @private + */ + [p.updateSetting](setting, newValue) { + const currentValue = this[p.values].get(setting); + if (currentValue === newValue) { + return; + } + + this[p.values].set(setting, newValue); + + if (newValue !== this[p.getDefaultSettingValue](setting)) { + this[p.storage].setItem(`${PREFIX}${setting.key}`, newValue); + } else { + this[p.storage].removeItem(`${PREFIX}${setting.key}`); + } + + this.emit( + setting.key.replace(UPPER_CASE_REGEX, (part) => `-${part.toLowerCase()}`), + newValue + ); + } + + /** + * Converts setting raw string value to the typed one depending on the setting + * type. + * + * @param {Object} setting Setting description object. + * @param {string?} stringValue Raw string setting value or null. + * @return {number|boolean|string|null} + * @private + */ + [p.stringToSettingTypedValue](setting, stringValue) { + // If string is null, we should return default value for this setting or + // default value for setting type. + if (stringValue === null) { + return this[p.getDefaultSettingValue](setting); + } else if (setting.type === 'boolean') { + return stringValue === 'true'; + } else if (setting.type === 'number') { + return Number(stringValue); + } + + return stringValue; + } + + /** + * Gets default typed value for the specified setting. + * + * @param {Object} setting Setting description object. + * @return {number|boolean|string|null} + * @private + */ + [p.getDefaultSettingValue](setting) { + if (setting.defaultValue !== undefined) { + return setting.defaultValue; + } + + // Default value for this setting is not specified, let's return default + // value for setting type (boolean, number or string). + if (setting.type === 'boolean') { + return false; + } else if (setting.type === 'number') { + return 0; + } + + return null; + } + + /** + * Handles localStorage "storage" event. + * + * @param {StorageEvent} evt StorageEvent instance. + * @private + */ + [p.onStorage](evt) { + if (!evt.key.startsWith(PREFIX)) { + return; + } + + const key = evt.key.substring(PREFIX.length); + const settingName = Object.keys(settings).find((settingName) => { + return settings[settingName].key === key; + }); + + if (!settingName) { + console.warn( + `Changed unknown storage entry with app specific prefix: ${evt.key}` + ); + return; + } + + const setting = settings[settingName]; + + this[p.updateSetting]( + setting, + this[p.stringToSettingTypedValue](setting, evt.newValue) + ); + } +} diff --git a/app/js/lib/server/webpush.js b/app/js/lib/server/webpush.js new file mode 100644 index 0000000..19935c2 --- /dev/null +++ b/app/js/lib/server/webpush.js @@ -0,0 +1,94 @@ +'use strict'; + +import EventDispatcher from '../common/event-dispatcher'; + +// Private members +const p = Object.freeze({ + // Properties, + api: Symbol('api'), + settings: Symbol('settings'), + + // Methods: + listenForMessages: Symbol('listenForMessages'), +}); + +export default class WebPush extends EventDispatcher { + constructor(api, settings) { + super(['message']); + + this[p.api] = api; + this[p.settings] = settings; + + Object.seal(this); + } + + subscribeToNotifications(resubscribe = false) { + if (!navigator.serviceWorker) { + return Promise.reject('No service worker supported'); + } + + navigator.serviceWorker.addEventListener('message', + this[p.listenForMessages].bind(this)); + + const settings = this[p.settings]; + if (settings.pushEndpoint && settings.pushPubKey && settings.pushAuth && + !resubscribe) { + return Promise.resolve(); + } + + return navigator.serviceWorker.ready + .then((reg) => reg.pushManager.subscribe({ userVisibleOnly: true })) + .then((subscription) => { + const endpoint = subscription.endpoint; + const key = subscription.getKey ? subscription.getKey('p256dh') : ''; + const auth = subscription.getKey ? subscription.getKey('auth') : ''; + settings.pushEndpoint = endpoint; + settings.pushPubKey = btoa(String.fromCharCode.apply(null, + new Uint8Array(key))); + settings.pushAuth = btoa(String.fromCharCode.apply(null, + new Uint8Array(auth))); + + // Send push information to the server. + // @todo We will need some library to write taxonomy messages. + const pushConfigurationMsg = [[ + [{ id: 'channel:subscribe.webpush@link.mozilla.org' }], + { + subscriptions: [{ + public_key: settings.pushPubKey, + push_uri: settings.pushEndpoint, + auth: settings.pushAuth, + }], + }, + ]]; + + return this[p.api].put('channels/set', pushConfigurationMsg); + }) + .then(() => { + // Setup some common push resources. + const pushResourcesMsg = [[ + [{ id: 'channel:resource.webpush@link.mozilla.org' }], + { resources: ['res1'] }, + ]]; + + return this[p.api].put('channels/set', pushResourcesMsg); + }) + .catch((error) => { + if (Notification.permission === 'denied') { + throw new Error('Permission request was denied.'); + } + + console.error('Error while saving subscription ', error); + throw new Error(`Subscription error: ${error}`); + }); + } + + [p.listenForMessages](evt) { + const msg = evt.data || {}; + + if (!msg.action) { + return; + } + + this.emit('message', msg); + } +} diff --git a/app/js/lib/speech-controller.js b/app/js/lib/speech-controller.js index 9709ecf..a1c6876 100644 --- a/app/js/lib/speech-controller.js +++ b/app/js/lib/speech-controller.js @@ -56,7 +56,7 @@ export default class SpeechController extends EventDispatcher { }); this[p.wakewordRecogniser] = wakeWordRecogniser; - this[p.wakewordModelUrl] = '/data/wakeword_model.json'; + this[p.wakewordModelUrl] = 'data/wakeword_model.json'; this[p.speechRecogniser] = speechRecogniser; diff --git a/app/js/views/microphone.jsx b/app/js/views/microphone.jsx index 3cbeb0b..0e0a458 100644 --- a/app/js/views/microphone.jsx +++ b/app/js/views/microphone.jsx @@ -9,9 +9,10 @@ export default class Microphone extends React.Component { }; this.speechController = props.speechController; + this.server = props.server; this.bleep = new Audio(); - this.bleep.src = '../media/cue.wav'; + this.bleep.src = 'media/cue.wav'; this.speechController.on('wakeheard', () => { this.bleep.pause(); @@ -26,6 +27,15 @@ export default class Microphone extends React.Component { this.click = this.click.bind(this); } + shouldComponentUpdate(nextProps, nextState) { + if (!this.server.isLoggedIn) { + return false; + } + + // @todo Find a better deep comparison method. + return JSON.stringify(this.state) !== JSON.stringify(nextState); + } + click() { if (!this.state.isListening) { this.bleep.pause(); @@ -41,12 +51,16 @@ export default class Microphone extends React.Component { } render() { + if (!this.server.isLoggedIn) { + return null; + } + const className = this.state.isListening ? 'listening' : ''; return (
- +
); } @@ -54,4 +68,5 @@ export default class Microphone extends React.Component { Microphone.propTypes = { speechController: React.PropTypes.object.isRequired, + server: React.PropTypes.object.isRequired, }; diff --git a/app/js/views/reminders.jsx b/app/js/views/reminders.jsx index 5f6071d..c39b98b 100644 --- a/app/js/views/reminders.jsx +++ b/app/js/views/reminders.jsx @@ -8,46 +8,28 @@ export default class Reminders extends React.Component { constructor(props) { super(props); + this.state = { + reminders: [], + }; + this.speechController = props.speechController; + this.server = props.server; moment.locale(navigator.languages || navigator.language || 'en-US'); + } - let k = 0; - - const reminders = [ - { - id: k++, - recipient: ['Guillaume'], - content: 'Get a haircut', - datetime: Date.now() + 1 * 60 * 60 * 1000, - }, - { - id: k++, - recipient: ['Guillaume'], - content: 'File a bug', - datetime: Date.now() + 2.5 * 60 * 60 * 1000, - }, - { - id: k++, - recipient: ['Julien'], - content: 'Do the laundry', - datetime: Date.now() + 1 * 60 * 60 * 24 * 1000, - }, - { - id: k++, - recipient: ['Sam'], - content: 'Attend ping pong competition', - datetime: Date.now() + 10.5 * 60 * 60 * 24 * 1000, - }, - { - id: k++, - recipient: ['Guillaume'], - content: 'Attend Swan Lake by the Bolshoi Ballet', - datetime: Date.now() + 45 * 60 * 60 * 24 * 1000, - }, - ]; - - this.state = { reminders }; + componentDidMount() { + this.server.reminders.getAll() + .then((reminders) => { + reminders = reminders.map((reminder) => ({ + id: reminder.id, + recipient: [reminder.recipient], + content: reminder.message, + datetime: reminder.due, + })); + + this.setState({ reminders }); + }); this.speechController.on('wakelistenstart', () => { console.log('wakelistenstart'); @@ -74,10 +56,24 @@ export default class Reminders extends React.Component { datetime: reminder.time, }); - this.setState(reminders); + this.setState({ reminders }); + + this.server.reminders.set({ + recipient: reminder.users.join(' '), + message: reminder.action, + due: Number(reminder.time), + }) + .then((res) => { + console.log(res); + }) + .catch((res) => { + console.error(res); + }); }); } + // @todo Add a different view when there's no reminders: + // 'You have no reminders. Why not asking to remind you of something?' render() { let reminders = this.state.reminders; @@ -143,4 +139,5 @@ export default class Reminders extends React.Component { Reminders.propTypes = { speechController: React.PropTypes.object.isRequired, + server: React.PropTypes.object.isRequired, }; diff --git a/app/js/views/user-login.jsx b/app/js/views/user-login.jsx index ea85dee..af7158f 100644 --- a/app/js/views/user-login.jsx +++ b/app/js/views/user-login.jsx @@ -1,10 +1,40 @@ import React from 'components/react'; export default class UserLogin extends React.Component { + constructor(props) { + super(props); + + this.state = { + login: 'mozilla', + }; + + this.server = props.server; + + this.onChange = this.onChange.bind(this); + this.onFormSubmit = this.onFormSubmit.bind(this); + } + + onChange(evt) { + const login = evt.target.value; + this.setState({ login }); + } + + onFormSubmit(evt) { + evt.preventDefault(); // Avoid redirection to /?. + + this.server.login(this.state.login, 'password') + .then(() => { + location.hash = 'reminders'; + }); + } + render() { return ( -
- + + @@ -12,3 +42,7 @@ export default class UserLogin extends React.Component { ); } } + +UserLogin.propTypes = { + server: React.PropTypes.object.isRequired, +};