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..3f4902a 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('speechController'),
+
+ 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..ba79317
--- /dev/null
+++ b/app/js/lib/server/api.js
@@ -0,0 +1,157 @@
+'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
+ * discovered and online, authenticated user session is established and
+ * document is visible).
+ *
+ * @returns {Promise}
+ * @private
+ */
+ [p.onceReady]() {
+ return Promise.all([
+ this[p.onceOnline](),
+ ]);
+ }
+
+ /**
+ * Returns 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..b373099
--- /dev/null
+++ b/app/js/lib/server/index.js
@@ -0,0 +1,106 @@
+/* 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;
+ }
+
+ /**
+ * Redirect the user to the box to get authenticated if she isn't already.
+ *
+ * @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 for accepting push notifications from the box.
+ * This method will be call 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 have 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..cf9baa0
--- /dev/null
+++ b/app/js/lib/server/network.js
@@ -0,0 +1,136 @@
+'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.
+ *
+ * @todo Detect if the URL is relative, if so prepend this.origin.
+ *
+ * @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..85cab47
--- /dev/null
+++ b/app/js/lib/server/reminders.js
@@ -0,0 +1,38 @@
+'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}`);
+ }
+
+ 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..cfb6df2
--- /dev/null
+++ b/app/js/lib/server/settings.js
@@ -0,0 +1,236 @@
+'use strict';
+
+import EventDispatcher from '../common/event-dispatcher';
+
+// Prefix all entries to avoid collisions.
+const PREFIX = 'calendar-';
+
+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 (
-