/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ var loop = loop || {}; loop.store = loop.store || {}; (function(mozL10n) { "use strict"; /** * Shared actions. * @type {Object} */ var sharedActions = loop.shared.actions; /** * Maximum size given to createRoom; only 2 is supported (and is * always passed) because that's what the user-experience is currently * designed and tested to handle. * @type {Number} */ var MAX_ROOM_CREATION_SIZE = loop.store.MAX_ROOM_CREATION_SIZE = 2; /** * Room validation schema. See validate.js. * @type {Object} */ var roomSchema = { roomToken: String, roomUrl: String, // roomName: String - Optional. // roomKey: String - Optional. maxSize: Number, participants: Array, ctime: Number }; /** * Room type. Basically acts as a typed object constructor. * * @param {Object} values Room property values. */ function Room(values) { var validatedData = new loop.validate.Validator(roomSchema || {}) .validate(values || {}); for (var prop in validatedData) { this[prop] = validatedData[prop]; } } loop.store.Room = Room; /** * Room store. * * @param {loop.Dispatcher} dispatcher The dispatcher for dispatching actions * and registering to consume actions. * @param {Object} options Options object: * - {mozLoop} mozLoop The MozLoop API object. * - {ActiveRoomStore} activeRoomStore An optional substore for active room * state. * - {Notifications} notifications An optional notifications item that is * required if create actions are to be used */ loop.store.RoomStore = loop.store.createStore({ /** * Maximum size given to createRoom; only 2 is supported (and is * always passed) because that's what the user-experience is currently * designed and tested to handle. * @type {Number} */ maxRoomCreationSize: MAX_ROOM_CREATION_SIZE, /** * Registered actions. * @type {Array} */ actions: [ "addSocialShareProvider", "createRoom", "createdRoom", "createRoomError", "copyRoomUrl", "deleteRoom", "deleteRoomError", "emailRoomUrl", "getAllRooms", "getAllRoomsError", "openRoom", "shareRoomUrl", "updateRoomContext", "updateRoomContextDone", "updateRoomContextError", "updateRoomList" ], initialize: function(options) { if (!options.mozLoop) { throw new Error("Missing option mozLoop"); } this._mozLoop = options.mozLoop; this._notifications = options.notifications; if (options.activeRoomStore) { this.activeRoomStore = options.activeRoomStore; this.activeRoomStore.on("change", this._onActiveRoomStoreChange.bind(this)); } }, getInitialStoreState: function() { return { activeRoom: this.activeRoomStore ? this.activeRoomStore.getStoreState() : {}, error: null, pendingCreation: false, pendingInitialRetrieval: true, rooms: [], savingContext: false }; }, /** * Registers mozLoop.rooms events. */ startListeningToRoomEvents: function() { // Rooms event registration this._mozLoop.rooms.on("add", this._onRoomAdded.bind(this)); this._mozLoop.rooms.on("update", this._onRoomUpdated.bind(this)); this._mozLoop.rooms.on("delete", this._onRoomRemoved.bind(this)); this._mozLoop.rooms.on("refresh", this._onRoomsRefresh.bind(this)); }, /** * Updates active room store state. */ _onActiveRoomStoreChange: function() { this.setStoreState({activeRoom: this.activeRoomStore.getStoreState()}); }, /** * Updates current room list when a new room is available. * * @param {String} eventName The event name (unused). * @param {Object} addedRoomData The added room data. */ _onRoomAdded: function(eventName, addedRoomData) { addedRoomData.participants = addedRoomData.participants || []; addedRoomData.ctime = addedRoomData.ctime || new Date().getTime(); this.dispatchAction(new sharedActions.UpdateRoomList({ // Ensure the room isn't part of the list already, then add it. roomList: this._storeState.rooms.filter(function(room) { return addedRoomData.roomToken !== room.roomToken; }).concat(new Room(addedRoomData)) })); }, /** * Executed when a room is updated. * * @param {String} eventName The event name (unused). * @param {Object} updatedRoomData The updated room data. */ _onRoomUpdated: function(eventName, updatedRoomData) { this.dispatchAction(new sharedActions.UpdateRoomList({ roomList: this._storeState.rooms.map(function(room) { return room.roomToken === updatedRoomData.roomToken ? updatedRoomData : room; }) })); }, /** * Executed when a room is deleted. * * @param {String} eventName The event name (unused). * @param {Object} removedRoomData The removed room data. */ _onRoomRemoved: function(eventName, removedRoomData) { this.dispatchAction(new sharedActions.UpdateRoomList({ roomList: this._storeState.rooms.filter(function(room) { return room.roomToken !== removedRoomData.roomToken; }) })); }, /** * Executed when the user switches accounts. * * @param {String} eventName The event name (unused). */ _onRoomsRefresh: function(eventName) { this.dispatchAction(new sharedActions.UpdateRoomList({ roomList: [] })); }, /** * Maps and sorts the raw room list received from the mozLoop API. * * @param {Array} rawRoomList Raw room list. * @return {Array} */ _processRoomList: function(rawRoomList) { if (!rawRoomList) { return []; } return rawRoomList .map(function(rawRoom) { return new Room(rawRoom); }) .slice() .sort(function(a, b) { return b.ctime - a.ctime; }); }, /** * Finds the next available room number in the provided room list. * * @param {String} nameTemplate The room name template; should contain a * {{conversationLabel}} placeholder. * @return {Number} */ findNextAvailableRoomNumber: function(nameTemplate) { var searchTemplate = nameTemplate.replace("{{conversationLabel}}", ""); var searchRegExp = new RegExp("^" + searchTemplate + "(\\d+)$"); var roomNumbers = this._storeState.rooms.map(function(room) { var match = searchRegExp.exec(room.decryptedContext.roomName); return match && match[1] ? parseInt(match[1], 10) : 0; }); if (!roomNumbers.length) { return 1; } return Math.max.apply(null, roomNumbers) + 1; }, /** * Generates a room names against the passed template string. * * @param {String} nameTemplate The room name template. * @return {String} */ _generateNewRoomName: function(nameTemplate) { var roomLabel = this.findNextAvailableRoomNumber(nameTemplate); return nameTemplate.replace("{{conversationLabel}}", roomLabel); }, /** * Creates a new room. * * @param {sharedActions.CreateRoom} actionData The new room information. */ createRoom: function(actionData) { this.setStoreState({ pendingCreation: true, error: null }); var roomCreationData = { decryptedContext: { roomName: this._generateNewRoomName(actionData.nameTemplate) }, maxSize: this.maxRoomCreationSize }; if ("urls" in actionData) { roomCreationData.decryptedContext.urls = actionData.urls; } this._notifications.remove("create-room-error"); this._mozLoop.rooms.create(roomCreationData, function(err, createdRoom) { var buckets = this._mozLoop.ROOM_CREATE; if (err) { this._mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_FAIL); this.dispatchAction(new sharedActions.CreateRoomError({error: err})); return; } this.dispatchAction(new sharedActions.CreatedRoom({ roomToken: createdRoom.roomToken })); this._mozLoop.telemetryAddValue("LOOP_ROOM_CREATE", buckets.CREATE_SUCCESS); // Since creating a room with context is only possible from the panel, // we can record that as the action here. var URLs = roomCreationData.decryptedContext.urls; if (URLs && URLs.length) { buckets = this._mozLoop.ROOM_CONTEXT_ADD; this._mozLoop.telemetryAddValue("LOOP_ROOM_CONTEXT_ADD", buckets.ADD_FROM_PANEL); } }.bind(this)); }, /** * Executed when a room has been created */ createdRoom: function(actionData) { this.setStoreState({pendingCreation: false}); // Opens the newly created room this.dispatchAction(new sharedActions.OpenRoom({ roomToken: actionData.roomToken })); }, /** * Executed when a room creation error occurs. * * @param {sharedActions.CreateRoomError} actionData The action data. */ createRoomError: function(actionData) { this.setStoreState({ error: actionData.error, pendingCreation: false }); // XXX Needs a more descriptive error - bug 1109151. this._notifications.set({ id: "create-room-error", level: "error", message: mozL10n.get("generic_failure_message") }); }, /** * Copy a room url. * * @param {sharedActions.CopyRoomUrl} actionData The action data. */ copyRoomUrl: function(actionData) { this._mozLoop.copyString(actionData.roomUrl); this._mozLoop.notifyUITour("Loop:RoomURLCopied"); var from = actionData.from; var bucket = this._mozLoop.SHARING_ROOM_URL["COPY_FROM_" + from.toUpperCase()]; if (typeof bucket === "undefined") { console.error("No URL sharing type bucket found for '" + from + "'"); return; } this._mozLoop.telemetryAddValue("LOOP_SHARING_ROOM_URL", bucket); }, /** * Emails a room url. * * @param {sharedActions.EmailRoomUrl} actionData The action data. */ emailRoomUrl: function(actionData) { loop.shared.utils.composeCallUrlEmail(actionData.roomUrl, null, actionData.roomDescription, actionData.from); this._mozLoop.notifyUITour("Loop:RoomURLEmailed"); }, /** * Share a room url. * * @param {sharedActions.ShareRoomUrl} actionData The action data. */ shareRoomUrl: function(actionData) { var providerOrigin = new URL(actionData.provider.origin).hostname; var shareTitle = ""; var shareBody = null; switch (providerOrigin) { case "mail.google.com": shareTitle = mozL10n.get("share_email_subject6"); shareBody = mozL10n.get("share_email_body6", { callUrl: actionData.roomUrl }); shareBody += mozL10n.get("share_email_footer"); break; case "twitter.com": default: shareTitle = mozL10n.get("share_tweet", { clientShortname2: mozL10n.get("clientShortname2") }); break; } this._mozLoop.socialShareRoom(actionData.provider.origin, actionData.roomUrl, shareTitle, shareBody); this._mozLoop.notifyUITour("Loop:RoomURLShared"); }, /** * Open the share panel to add a Social share provider. * * @param {sharedActions.AddSocialShareProvider} actionData The action data. */ addSocialShareProvider: function(actionData) { this._mozLoop.addSocialShareProvider(); }, /** * Creates a new room. * * @param {sharedActions.DeleteRoom} actionData The action data. */ deleteRoom: function(actionData) { this._mozLoop.rooms.delete(actionData.roomToken, function(err) { var buckets = this._mozLoop.ROOM_DELETE; if (err) { this.dispatchAction(new sharedActions.DeleteRoomError({error: err})); } this._mozLoop.telemetryAddValue("LOOP_ROOM_DELETE", buckets[err ? "DELETE_FAIL" : "DELETE_SUCCESS"]); }.bind(this)); }, /** * Executed when a room deletion error occurs. * * @param {sharedActions.DeleteRoomError} actionData The action data. */ deleteRoomError: function(actionData) { this.setStoreState({error: actionData.error}); }, /** * Gather the list of all available rooms from the MozLoop API. */ getAllRooms: function() { this._mozLoop.rooms.getAll(null, function(err, rawRoomList) { var action; this.setStoreState({pendingInitialRetrieval: false}); if (err) { action = new sharedActions.GetAllRoomsError({error: err}); } else { action = new sharedActions.UpdateRoomList({roomList: rawRoomList}); } this.dispatchAction(action); // We can only start listening to room events after getAll() has been // called executed first. this.startListeningToRoomEvents(); }.bind(this)); }, /** * Updates current error state in case getAllRooms failed. * * @param {sharedActions.GetAllRoomsError} actionData The action data. */ getAllRoomsError: function(actionData) { this.setStoreState({error: actionData.error}); }, /** * Updates current room list. * * @param {sharedActions.UpdateRoomList} actionData The action data. */ updateRoomList: function(actionData) { this.setStoreState({ error: undefined, rooms: this._processRoomList(actionData.roomList) }); }, /** * Opens a room * * @param {sharedActions.OpenRoom} actionData The action data. */ openRoom: function(actionData) { this._mozLoop.rooms.open(actionData.roomToken); }, /** * Updates the context data attached to a room. * * @param {sharedActions.UpdateRoomContext} actionData */ updateRoomContext: function(actionData) { this.setStoreState({ savingContext: true }); this._mozLoop.rooms.get(actionData.roomToken, function(err, room) { if (err) { this.dispatchAction(new sharedActions.UpdateRoomContextError({ error: err })); return; } var roomData = {}; var context = room.decryptedContext; var oldRoomName = context.roomName; var newRoomName = actionData.newRoomName.trim(); if (newRoomName && oldRoomName !== newRoomName) { roomData.roomName = newRoomName; } var oldRoomURLs = context.urls; var oldRoomURL = oldRoomURLs && oldRoomURLs[0]; // Since we want to prevent storing falsy (i.e. empty) values for context // data, there's no need to send that to the server as an update. var newRoomURL = loop.shared.utils.stripFalsyValues({ location: actionData.newRoomURL ? actionData.newRoomURL.trim() : "", thumbnail: actionData.newRoomURL ? actionData.newRoomThumbnail.trim() : "", description: actionData.newRoomDescription ? actionData.newRoomDescription.trim() : "" }); // Only attach a context to the room when // 1) there was already a URL set, // 2) a new URL is provided as of now, // 3) the URL data has changed. var diff = loop.shared.utils.objectDiff(oldRoomURL, newRoomURL); if (diff.added.length || diff.updated.length) { newRoomURL = _.extend(oldRoomURL || {}, newRoomURL); var isValidURL = false; try { isValidURL = new URL(newRoomURL.location); } catch(ex) { // URL may throw, default to false; } if (isValidURL) { roomData.urls = [newRoomURL]; } } // TODO: there currently is no clear UX defined on what to do when all // context data was cleared, e.g. when diff.removed contains all the // context properties. Until then, we can't deal with context removal here. // When no properties have been set on the roomData object, there's nothing // to save. if (!Object.getOwnPropertyNames(roomData).length) { // Ensure async actions so that we get separate setStoreState events // that React components won't miss. setTimeout(function() { this.dispatchAction(new sharedActions.UpdateRoomContextDone()); }.bind(this), 0); return; } var hadContextBefore = !!oldRoomURL; this.setStoreState({error: null}); this._mozLoop.rooms.update(actionData.roomToken, roomData, function(error, data) { var action = error ? new sharedActions.UpdateRoomContextError({ error: error }) : new sharedActions.UpdateRoomContextDone(); this.dispatchAction(action); if (!err && !hadContextBefore) { // Since updating the room context data is only possible from the // conversation window, we can assume that any newly added URL was // done from there. var buckets = this._mozLoop.ROOM_CONTEXT_ADD; this._mozLoop.telemetryAddValue("LOOP_ROOM_CONTEXT_ADD", buckets.ADD_FROM_CONVERSATION); } }.bind(this)); }.bind(this)); }, /** * Handles the updateRoomContextDone action. */ updateRoomContextDone: function() { this.setStoreState({ savingContext: false }); }, /** * Updating the context data attached to a room error. * * @param {sharedActions.UpdateRoomContextError} actionData */ updateRoomContextError: function(actionData) { this.setStoreState({ error: actionData.error, savingContext: false }); } }); })(document.mozL10n || navigator.mozL10n);