From 17cc7c2bcdde7f758c17825756175f2395872916 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 27 Sep 2017 15:08:57 +0200 Subject: [PATCH] Coediting: Refactor Collaborative Editing to be more independent from Gutenberg --- blocks/editable/index.js | 2 +- grtc/DESIGN.md => coediting/ARCHITECTURE.md | 117 ++--- coediting/README.md | 59 +++ coediting/hooks/index.js | 67 +++ coediting/hooks/style.scss | 80 +++ coediting/index.js | 185 +++++++ coediting/signal.js | 94 ++++ coediting/store/index.js | 184 +++++++ coediting/store/middleware.js | 130 +++++ coediting/store/test/index.js | 264 ++++++++++ coediting/utils/index.js | 19 + coediting/utils/test/index.js | 35 ++ editor/actions.js | 30 -- editor/assets/stylesheets/_variables.scss | 7 - editor/components/block-list/block.js | 24 +- editor/components/block-list/style.scss | 70 --- .../sidebar/coediting-panel/index.js | 42 ++ .../edit-post/sidebar/collaboration/index.js | 42 -- .../edit-post/sidebar/post-settings/index.js | 4 +- editor/index.js | 1 + editor/selectors.js | 46 +- editor/store/reducer.js | 158 +----- editor/store/store.js | 4 +- grtc/README.md | 120 ----- grtc/app.js | 471 ------------------ grtc/crypto.js | 32 -- lib/coediting.php | 194 ++++++++ lib/collaboration.php | 204 -------- lib/load.php | 2 +- package-lock.json | 35 +- package.json | 1 - 31 files changed, 1434 insertions(+), 1289 deletions(-) rename grtc/DESIGN.md => coediting/ARCHITECTURE.md (56%) create mode 100644 coediting/README.md create mode 100644 coediting/hooks/index.js create mode 100644 coediting/hooks/style.scss create mode 100644 coediting/index.js create mode 100644 coediting/signal.js create mode 100644 coediting/store/index.js create mode 100644 coediting/store/middleware.js create mode 100644 coediting/store/test/index.js create mode 100644 coediting/utils/index.js create mode 100644 coediting/utils/test/index.js create mode 100644 editor/edit-post/sidebar/coediting-panel/index.js delete mode 100644 editor/edit-post/sidebar/collaboration/index.js delete mode 100644 grtc/README.md delete mode 100644 grtc/app.js delete mode 100644 grtc/crypto.js create mode 100644 lib/coediting.php delete mode 100644 lib/collaboration.php diff --git a/blocks/editable/index.js b/blocks/editable/index.js index c7d365d336b929..dcf76af8089360 100644 --- a/blocks/editable/index.js +++ b/blocks/editable/index.js @@ -709,7 +709,7 @@ export default class Editable extends Component { getContent() { return this.editor.getContent(); - return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement ); + // return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement ); } updateFocus() { diff --git a/grtc/DESIGN.md b/coediting/ARCHITECTURE.md similarity index 56% rename from grtc/DESIGN.md rename to coediting/ARCHITECTURE.md index 804a509f4fa166..91dfe4cfbafd00 100644 --- a/grtc/DESIGN.md +++ b/coediting/ARCHITECTURE.md @@ -1,10 +1,6 @@ -# Design +# Architecture -### GRTC module has 3 classes - -* Signal -* Transport -* GRTC (main module) +Coediting module contains 2 classes. ### Signal @@ -17,80 +13,51 @@ * updateSignal : Update the signal or any meta data by sending request to `/set/{key}/base64({value})` **Signal Structure** +```json +[ + { + "initiator": true, + "peer_id": "10302c30-6795-11e7-8104-0dba91a90a01", + "signal": { + "sdp": "SIGNAL", + "type": "offer" + }, + "type": "initial", + "user_id": 123456789 + }, + { + "initiator": false, + "peer_id": "15615da0-6795-11e7-93b4-efefd373081b", + "signal": { + "sdp": "SIGNAL", + "type": "answer" + }, + "type": "initial", + "user_id": 987654321 + } +] ``` -key: [{ - "peerID": "10302c30-6795-11e7-8104-0dba91a90a01", - "type": "initial", - "initiator": true, - "signal": { - "type": "offer", - "sdp": "SIGNAL" - } - }, - { - "peerID": "15615da0-6795-11e7-93b4-efefd373081b", - "type": "initial", - "initiator": false, - "signal": { - "type": "answer", - "sdp": "SIGNAL" - } - }] - ``` - -
- -### Transport ( Optional, default is false ) - -It uses RSA 1024 bit encryption to generate the public/private keypairs. Which is sent to the initiator and it encrypts the shared secret key and sent to peers who have shared their own public keys. The shared secret key is used to encrypt ( AES in CBC mode ) data which flows through rtc data channel. - -**1024 bit RSA is not that easily breakable specially for a session which is a very short-term and every reload in browser will generate new key pairs** - -**Methods** - -* encrypt : Its used internally by GRTC class to encrypt data before sending to rtc channel. - -* decrypt : Its again used by GRTC class to decrypt data before emitting the event of data received. - -
- -### GRTC ( Main Class ) - -This is the main class which is initiated by a user directly and required three parameters. A unique ID and server location on which to send and receive signal and if want to use transport layer or not. +### Coediting ( Main Class ) -**Note: Using Encrypted Session will add a delay of 1-2 seconds on the modern machine due to the generation of RSA public/private key pairs. This is why it's disabled by default. WebRTC has its own security layer called DTLS but for end to end encryption and if you don't trust TURN server you can enable it.** - -* Reference 1: https://stackoverflow.com/questions/23085335/is-webrtc-traffic-over-turn-end-to-end-encrypted -* Reference 2: http://webrtc-security.github.io/ +This is the main class which is initiated by a user directly and required three parameters. A unique identifier and server location on which to send and receive signal. **Methods** - -* randomColor : generates a random color which uniquely describes a user in another user browser - * uuid : Returns a unique uuid which is used to create a unique document id and also used to create a shared secret key. -* secret : Creates a AES compatible key using `uuid` function - -* setDifference : Finds out the set difference this is crucial if you have more than 3 peers and want to find out who is the recent one. - -* isInitiator : Send the request to the server to ask if he is initiator or not. +* isInitiator : Send the request to the server to ask if they is initiator or not. -* listenSignal : Start the timer with interval of 3 seconds to run function `listenSignalRoutine` +* listenSignal : Start the timer with interval of 3 seconds to run function `listenSignalRoutine`. -* listenSignalRoutine : Send request to server to fetch new data for a key ( document unique id ) +* listenSignalRoutine : Send request to server to fetch new data for a key ( document unique id ). * dataHandler : Looks for specific keys in data received before emitting event `peerData` to user and acts as middleware. * peerHandler : Acts as an abstraction over simple peer library used for this module and handles initiator and events related to peer. -* securityHandler : Handles the sharing of a public key and receiving of a public key and shared the secret key. - -* startTransportLayer : Starts sending encrypted data instead of raw data if its enabled in the constructor. - -* init : Main entry point of GRTC module. Sets up initial stuff like decide on the initiator or whether to enable encrypted session or not. +* init : Main entry point of Coediting module.
@@ -100,19 +67,19 @@ Problems with 2 or more than 2 peers. Notice that number of initiators are `n-1 To understand problems with XHR based signaling let's understand how it currently works with 2 peers. -1. Server currently has empty set for a key `X` +1. Server currently has empty set for a key `X`. -2. New user comes and become `Peer1` and send request to server to see if he can become `Initiator` for key `X` +2. New user comes and become `Peer1` and send request to server to see if they can become `Initiator` for key `X`. -3. Server sees ``` `X` -> empty Set {} ``` and returns true for `Initiator` to `Peer1` +3. Server sees ``` `X` -> empty Set {} ``` and returns true for `Initiator` to `Peer1`. 4. `Peer1` gets true and mark itself as `Initiator` and starts listening for other peers signal. -5. New user comes now and become `Peer2` and send request to server asking if he can become `Initiator` +5. New user comes now and become `Peer2` and send request to server asking if they can become `Initiator`. 6. `Peer2` gets false and marks itself as non-initiator and starts listening for `Initiator` signals by polling at 3 seconds intervals. -7. Both `Peer1` and `Peer2` see each other signal and starts handshake defined in way in simple-peer library ( https://github.com/feross/simple-peer ) +7. Both `Peer1` and `Peer2` see each other signal and starts handshake defined in way in simple-peer library ( https://github.com/feross/simple-peer). ### Problems @@ -161,11 +128,11 @@ The solution to this problem can be solved if the server doesn't have code execu An algorithm that can possibly make this work. -**We would require something unique which describes a peer even after refresh. One thing is username of WordPress account or using some UUID which can be stored in local storage** +**We would require something unique which describes a peer even after refresh. One thing is user id of WordPress account or using some UUID which can be stored in local storage.** Steps: -1. Both peers send username along with other meta data to the server and follow the steps for initial handshake. +1. Both peers send user id along with other meta data to the server and follow the steps for initial handshake. 2. The server has data about which peer was the initiator and which wasn't. @@ -178,16 +145,16 @@ Case 2: Peer1 which was initiator before doesn't return but now its someone else Peer3 even then server knows who was initiator and non-initiator then logic applies from first solution -**Limitation if username is chosen for uniqueness** +**Limitation if user id is chosen for uniqueness** -Single user won't be able to collaborate even if he opens the collaboration url in different browser as long as he is using same user login +A single user won't be able to collaborate even if they open the coediting url in a different browser when logged in using the same user account. **Limitation if uuid is stored in local storage of browser** -If peer1 switches browser again he will be treated as new peer3 and can be handled in same way as described in case2 above. +If peer1 switches browser again they will be treated as new peer3 and can be handled in same way as described in case2 above. ### When more than 2 peers ( Not implemented ) --- -The Same approach which was used in problem2 can be used here but the number of requests becomes huge to the server to decide on who to become the initiator and the full mesh topology applies here. There will be few api changes too in main GRTC module too. +The Same approach which was used in problem2 can be used here but the number of requests becomes huge to the server to decide on who to become the initiator and the full mesh topology applies here. There will be few api changes too in main Coediting module too. diff --git a/coediting/README.md b/coediting/README.md new file mode 100644 index 00000000000000..591a72eb831ba6 --- /dev/null +++ b/coediting/README.md @@ -0,0 +1,59 @@ +# Gutenberg Collaborative Editing Plugin +## Based on WebRTC +--- + +### Starting App +Peer starting coediting has to generate a uuid using: +``` +const coeditingId = Coediting.uuid(); // static function +``` + +After that pass that to Coediting module: + +``` +window.history.replaceState( '', '', '#' + coeditingId ); +const coediting = new Coediting( coeditingId ); +``` + +Peer not starting coediting has to join and get that coeditingId somehow possibly by sharing url. + +___ + +## API + +**Events** + +* `peerFound` - checked via long polling to /get/coeditingId route to server. +``` +coediting.on( 'peerFound', function( peer ) { + // peer => peer signal used for connection establishment +} ); +``` + +* `peerSignal` - received from other peer as offer. +``` +coediting.on('peerSignal', function(signal){ + // signal => signal that is received from another peer. +}); +``` + +* `peerConnected` - emitted after peerSignal and connection is established. +``` +coediting.on('peerConnected', function(){ + // peer is connected. +}); +``` + +* `peerData - triggered when data is received. +``` +coediting.on('peerData', function(data){ + //data is always json stringified +}); +``` + + +## Data Format + +Payload should always be JSON object which can be sent directly using coediting.send without stringify. + + diff --git a/coediting/hooks/index.js b/coediting/hooks/index.js new file mode 100644 index 00000000000000..658d087821a6a8 --- /dev/null +++ b/coediting/hooks/index.js @@ -0,0 +1,67 @@ +/** + * External dependency + */ +import { connect } from 'react-redux'; + +/** + * WordPress dependency + */ +import { BlockEdit, getBlockType } from '@wordpress/blocks'; +import { addFilter } from '@wordpress/hooks'; +import { getWrapperDisplayName } from '@wordpress/element'; + +/** + * Internal dependency + */ +import './style.scss'; +// TODO: Move selectors to the local folder +import { + getFrozenBlockCollaboratorColor, + getFrozenBlockCollaboratorName, + isBlockFrozenByCollaborator, +} from '../store'; + +const withFrozenMode = ( BloclListBlock ) => { + const WrappedBlockItem = ( { collaboratorColor, collaboratorName, isFrozenByCollaborator, ...props } ) => { + if ( ! isFrozenByCollaborator ) { + return ; + } + + const { block } = props; + const { attributes, name, isValid, uid } = block; + const blockType = getBlockType( name ); + + // Determine whether the block has props to apply to the wrapper. + let wrapperProps; + if ( blockType.getEditWrapperProps ) { + wrapperProps = blockType.getEditWrapperProps( attributes ); + } + + return ( +
+ { collaboratorName } +
+ { isValid && } +
+
+ ); + }; + WrappedBlockItem.displayName = getWrapperDisplayName( BloclListBlock, 'frozen-mode' ); + + const mapStateToProps = ( state, { uid } ) => ( { + collaboratorColor: getFrozenBlockCollaboratorColor( state, uid ), + collaboratorName: getFrozenBlockCollaboratorName( state, uid ), + isFrozenByCollaborator: isBlockFrozenByCollaborator( state, uid ), + } ); + + return connect( mapStateToProps )( WrappedBlockItem ); +}; + +addFilter( 'editor.BlockListBlock', 'coediting/block-item/frozen-mode', withFrozenMode ); diff --git a/coediting/hooks/style.scss b/coediting/hooks/style.scss new file mode 100644 index 00000000000000..ccfbd03228489e --- /dev/null +++ b/coediting/hooks/style.scss @@ -0,0 +1,80 @@ +$coediting-green: #46b450; +$coediting-orange: #f56e28; +$coediting-purple: #826eb4; +$coediting-red: #dc3232; +$coediting-yellow: #ffb900; + +.is-frozen-by-collaborator { + &.editor-block-list__block { + cursor: not-allowed; + pointer-events: none; + user-select: none; + + &:before { + outline: 1px solid; + } + + .coediting-legend { + color: $white; + font-size: $default-font-size; + padding: 2px 5px; + position: absolute; + right: 0; + top: 0; + z-index: 50; + + @include break-small { + right: $block-mover-padding-visible + } + } + + &.is-green { + &:before { + outline-color:$coediting-green; + } + .coediting-legend { + background-color: $coediting-green; + } + } + + &.is-orange { + &:before { + outline-color:$coediting-orange; + } + .coediting-legend { + background-color: $coediting-orange; + } + } + + &.is-purple { + &:before { + outline-color:$coediting-purple; + } + .coediting-legend { + background-color: $coediting-purple; + } + } + + &.is-red { + &:before { + outline-color:$coediting-red; + } + .coediting-legend { + background-color: $coediting-red; + } + } + + &.is-yellow { + &:before { + outline-color:$coediting-yellow; + } + .coediting-legend { + background-color: $coediting-yellow; + } + } + } + + .editor-block-list__block-edit { + opacity: 0.8; + } +} diff --git a/coediting/index.js b/coediting/index.js new file mode 100644 index 00000000000000..7ff86efdc59540 --- /dev/null +++ b/coediting/index.js @@ -0,0 +1,185 @@ +/** + * External dependencies + */ +import Peer from 'simple-peer'; +import uuidv1 from 'uuid/v1'; +import { EventEmitter } from 'events'; + +/** + * WordPress dependencies + */ +import { camelCaseKeysDeep } from './utils'; + +/** + * Internal dependencies + */ +import Signal from './signal'; + +export default class Coediting extends EventEmitter { + /** + * @param {string} coeditingId global id representing document. + * uuid is uniquely generated id for coediting to happen + */ + constructor( coeditingId ) { + super(); + this.peer = null; + this.peerSignal = null; + this.signalInstance = null; + this.url = '/wp-json/coediting'; + this.coeditingId = coeditingId; + this.peerId = Coediting.uuid(); + this.userId = null; + this.otherPeers = new Set(); + this.listenSignalTimer = 0; + this.listenSignalCount = 0; + this.isConnected = false; + this.keys = []; + this.init(); + } + + /** + * Generates uuid which is used for url unique hash. + * @return {string} uuid. + */ + static uuid() { + return uuidv1(); + } + + /** + * Check if you are initiator or not. + */ + isInitiator() { + const data = { + peer_id: this.peerId, + type: 'initial', + }; + + window.fetch( this.url + '/set', { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + body: `${ encodeURIComponent( this.coeditingId ) }=${ window.btoa( JSON.stringify( data ) ) }`, + } ).then( + resp => resp.json() + ).then( ( resp ) => { + this.emit( 'initiator', camelCaseKeysDeep( resp ) ); + } ).catch( () => { + this.emit( 'initiator', false ); + } ); + } + + /** + * Listens for signals after finding initiator. + */ + listenSignal() { + this.listenSignalRoutine(); + this.listenSignalTimer = setInterval( () => { + this.listenSignalCount++; + this.listenSignalRoutine(); + }, 3000 ); + } + + /** + * signal routine to continue in loop + */ + listenSignalRoutine() { + this.signalInstance.getSignal().then( ( resp ) => { + resp.forEach( ( peer ) => { + if ( peer.peerId !== this.peerId && ! this.otherPeers.has( peer.peerId ) && peer.signal ) { + this.otherPeers.add( peer.peerId ); + this.emit( 'peerFound', peer ); + } + } ); + } ); + } + + /** + * peerHandler returns peer and signalReceived. + * signal received is immediate if initiator is true. + * signal received is not present if initiator is false it waits for initiator signal. + * @return {Promise} promise object + */ + peerHandler() { + return new Promise( ( resolve ) => { + this.peer = new Peer( { + initiator: this.initiator === true, + trickle: false, + } ); + + this.peer.on( 'signal', ( peerSignal ) => { + this.peerSignal = peerSignal; + resolve(); + } ); + + this.on( 'peerFound', ( peer ) => { + this.peer.signal( peer.signal ); + } ); + + this.peer.on( 'signal', ( data ) => { + this.emit( 'peerSignal', data ); + } ); + + this.peer.on( 'connect', () => { + this.emit( 'peerConnected' ); + this.isConnected = true; + } ); + + this.peer.on( 'data', ( data ) => { + const parsedData = JSON.parse( data ); + + this.emit( 'peerData', parsedData ); + } ); + + this.peer.on( 'close', () => { + this.isConnected = false; + this.emit( 'peerClosed', ); + clearInterval( this.listenSignalTimer ); + delete this._events.initiator; + delete this._events.peerFound; + this.init(); + } ); + + /** + * Override peer send to automatically convert json. + * @param {string} data json wrapper + */ + this.send = function( data ) { + this.peer.send( JSON.stringify( data ) ); + }; + } ); + } + + /** + * Called by contructor and main entry point of app. + */ + init() { + this.isInitiator(); + + /** + * Server decides if you get to initiate or not. + * Because of persistance independent of peers. + */ + this.on( 'initiator', ( resp ) => { + // If not initiator start listening for signals. + this.initiator = resp.initiator; + this.userId = resp.userId; + if ( ! this.initiator ) { + this.signalInstance = new Signal( this.url, this.coeditingId, this.peerId, this.userId, this.peerSignal ); + this.listenSignal(); + } + + /** + * Will be resolved after initiator is set only. + */ + this.peerHandler().then( () => { + this.signalInstance = new Signal( this.url, this.coeditingId, this.peerId, this.userId, this.peerSignal ); + this.signalInstance.updateSignal().then( () => { + this.listenSignal(); + } ); + } ); + } ); + } +} diff --git a/coediting/signal.js b/coediting/signal.js new file mode 100644 index 00000000000000..8fc6ddbd5ce20b --- /dev/null +++ b/coediting/signal.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { camelCaseKeysDeep } from './utils'; + +/** + * Signal class that uses promise not event emitters and can be used standalone. + * getSignal and updateSignal lets you update and fetch data regarding the shared key. + */ +export default class Signal { + /** + * @param {string} url Where signal should go. + * @param {string} coeditingId Global id shared by url. + * @param {string} peerId Peer identifier that maps to user id. + * @param {string} userId User id that maps to peerId. + * @param {object} signalId Signal generated by peer used to traverse and connect P2P + */ + constructor( url, coeditingId, peerId, userId, signalId ) { + this.url = url; + this.coeditingId = coeditingId; + this.peerId = peerId; + this.signalId = signalId; + this.userId = userId; + } + + /** + * Clear the key forcefully in kv. + * @return {Promise} promise object + */ + clearSignal() { + const data = { + peer_id: this.peerId, + type: 'initial', + signal: this.signalId, + user_id: this.userId, + }; + + return window.fetch( this.url + '/remove', { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + body: `${ encodeURIComponent( this.coeditingId ) }=${ window.btoa( JSON.stringify( data ) ) }`, + } ).then( + resp => resp.json() + ).then( + camelCaseKeysDeep + ); + } + + /** + * Called periodically in order to fetch the updated signal. + * @return {Promise} promise object + */ + getSignal() { + return window.fetch( this.url + '/get/' + this.coeditingId, { + credentials: 'same-origin', + method: 'GET', + } ).then( + resp => resp.json() + ).then( + camelCaseKeysDeep + ); + } + + /** + * Updates the server route so that peers can get the data. + * @return {Promise} promise object + */ + updateSignal() { + const data = { + peer_id: this.peerId, + signal: this.signalId, + type: 'register', + user_id: this.userId, + }; + + return window.fetch( this.url + '/set', { + credentials: 'same-origin', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + body: `${ encodeURIComponent( this.coeditingId ) }=${ window.btoa( JSON.stringify( data ) ) }`, + } ).then( + resp => resp.json() + ).then( + camelCaseKeysDeep + ); + } +} diff --git a/coediting/store/index.js b/coediting/store/index.js new file mode 100644 index 00000000000000..52efa6748df267 --- /dev/null +++ b/coediting/store/index.js @@ -0,0 +1,184 @@ +/** + * External dependencies + */ +import { combineReducers } from 'redux'; +import { get, has, omit, omitBy, some } from 'lodash'; + +const colors = [ 'red', 'purple', 'orange', 'yellow', 'green' ]; + +const pickColor = () => { + return colors[ Math.floor( Math.random() * colors.length ) ]; +}; + +/** + * Reducer returning frozen blocks state when coediting. + * + * @param {Boolean} state Current state + * @param {Object} action Dispatched action + * @return {Boolean} Updated state + */ +export const blocks = ( state = {}, action ) => { + const collaboratorId = get( action, 'meta.collaboratorId', null ); + const isCurrentCollaborator = value => value === collaboratorId; + + if ( ! collaboratorId ) { + return state; + } + + switch ( action.type ) { + case 'COEDITING_BLOCKS_UNFREEZE': + if ( ! some( state, isCurrentCollaborator ) ) { + return state; + } + + return omitBy( state, isCurrentCollaborator ); + case 'COEDITING_FREEZE_BLOCK': + if ( state[ action.uid ] === collaboratorId ) { + return state; + } + + return { + ...omitBy( state, isCurrentCollaborator ), + [ action.uid ]: collaboratorId, + }; + } + return state; +}; + +/** + * Reducer returning collaborators state when coediting. + * + * @param {Boolean} state Current state + * @param {Object} action Dispatched action + * @return {Boolean} Updated state + */ +export const collaborators = ( state = {}, action ) => { + switch ( action.type ) { + case 'COEDITING_COLLABORATOR_ADD': + return { + ...state, + [ action.collaboratorId ]: { + color: pickColor(), + name: `User: ${ action.userId }`, + userId: action.userId, + }, + }; + case 'COEDITING_COLLABORATOR_REMOVE': + if ( ! state[ action.collaboratorId ] ) { + return state; + } + + return omit( state, action.collaboratorId ); + } + return state; +}; + +/** + * Reducer returning coediting enabled state. + * + * @param {Boolean} state Current state + * @param {Object} action Dispatched action + * @return {Boolean} Updated state + */ +export const enabled = ( state = false, action ) => { + switch ( action.type ) { + case 'COEDITING_TOGGLE': + return ! state; + } + return state; +}; + +export const reducer = combineReducers( { + blocks, + collaborators, + enabled, +} ); + +/** + * Returns an action object used to clear the freeze for blocks edited by the collaborator. + * + * @return {Object} Action object + */ +export function clearFrozenBlocks() { + return { + type: 'COEDITING_BLOCKS_UNFREEZE', + }; +} + +/** + * Returns an action object used to freeze the block edited by the collaborator. + * + * @param {String} uid Block unique ID + * @return {Object} Action object + */ +export function freezeBlock( uid ) { + return { + type: 'COEDITING_FREEZE_BLOCK', + uid, + }; +} + +/** + * Returns an action object used to enable/disable a coediting session. + * + * @return {Object} Action object + */ +export function toggleCoediting() { + return { + type: 'COEDITING_TOGGLE', + }; +} + +const getFrozenBlockCollaboratorProp = prop => + ( state, uid ) => { + const collaboratorId = get( state, [ 'coediting', 'blocks', uid ], null ); + + if ( collaboratorId === null ) { + return null; + } + + return get( state, [ 'coediting', 'collaborators', collaboratorId, prop ], null ); + }; + +/** + * Returns a color assigned to the collaborator when block is frozen or null otherwise. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * + * @return {String|null} Color when block frozen, null otherwise + */ +export const getFrozenBlockCollaboratorColor = getFrozenBlockCollaboratorProp( 'color' ); + +/** + * Returns a name assigned to the collaborator when block is frozen or null otherwise. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * + * @return {String|null} Whether coediting is enabled + */ +export const getFrozenBlockCollaboratorName = getFrozenBlockCollaboratorProp( 'name' ); + +/** + * Returns true when a block is frozen by the collaborator or false otherwise. + * + * @param {Object} state Global application state + * @param {String} uid Block unique ID + * + * @return {Boolean} Whether block is frozen by collaborator + */ +export function isBlockFrozenByCollaborator( state, uid ) { + return has( state, [ 'coediting', 'blocks', uid ], false ); +} + +/** + * Returns true when coediting is enabled, or false otherwise. + * + * @param {Object} state Global application state + * + * @return {Boolean} Whether coediting is enabled + */ +export function isCoeditingEnabled( state ) { + return get( state, 'coediting.enabled', false ); +} diff --git a/coediting/store/middleware.js b/coediting/store/middleware.js new file mode 100644 index 00000000000000..7fdd2bf445878d --- /dev/null +++ b/coediting/store/middleware.js @@ -0,0 +1,130 @@ +/** + * External dependencies + */ +import { + get, + includes, +} from 'lodash'; + +/** + * Internal dependencies + */ +import Coediting from '../'; +import { + clearFrozenBlocks, + freezeBlock, + isCoeditingEnabled, + toggleCoediting, +} from 'coediting/store'; + +export default function coeditingMiddleware( { dispatch, getState } ) { + let app = null; + + const isPeerConnected = () => app && app.isConnected; + + const getActionCollaboratorId = ( action ) => get( action, 'meta.collaboratorId', null ); + + const isCoeditingAction = ( action ) => getActionCollaboratorId( action ) !== null; + + // Starts the coediting and enables its API. + function startCoedting() { + let coeditingId; + if ( window.location.hash.length === 0 ) { + coeditingId = Coediting.uuid(); + window.history.replaceState( '', '', '#' + coeditingId ); + } else { + coeditingId = window.location.hash.slice( 1, window.location.hash.length ); + } + + app = new Coediting( coeditingId ); + app.on( 'peerData', onPeerData ); + app.on( 'peerFound', function( { peerId, userId } ) { + // TODO: Convert to action + dispatch( { + type: 'COEDITING_COLLABORATOR_ADD', + collaboratorId: peerId, + userId, + } ); + } ); + // TODO: Remove collaborators when disconnected + } + + const stopCoediting = () => { + if ( ! app ) { + return; + } + + if ( isPeerConnected() ) { + app.peer.destroy(); + } + app = null; + window.history.replaceState( '', '', '#' ); + }; + + const peerSend = ( action ) => { + if ( ! isPeerConnected() || isCoeditingAction( action ) ) { + return; + } + + const allowedActions = [ + 'COEDITING_BLOCKS_UNFREEZE', + 'COEDITING_FREEZE_BLOCK', + 'EDIT_POST', + 'INSERT_BLOCKS', + 'MOVE_BLOCKS_DOWN', + 'MOVE_BLOCKS_UP', + 'REMOVE_BLOCKS', + 'REPLACE_BLOCKS', + 'UPDATE_BLOCK_ATTRIBUTES', + ]; + + if ( ! includes( allowedActions, action.type ) ) { + return; + } + + app.send( { + ...action, + meta: { + collaboratorId: app.peerId, + }, + } ); + }; + + function onPeerData( action ) { + if ( getActionCollaboratorId( action ) !== app.peerId ) { + dispatch( action ); + } + } + + return next => action => { + const returnValue = next( action ); + + const enabled = isCoeditingEnabled( getState() ); + + switch ( action.type ) { + case 'SETUP_EDITOR': + // If url is shared, enable the coediting mode automatically. + if ( ! enabled && window.location.hash.length !== 0 ) { + dispatch( toggleCoediting() ); + } + break; + case 'COEDITING_TOGGLE': + if ( enabled ) { + startCoedting(); + } else { + stopCoediting(); + } + break; + case 'SELECT_BLOCK': + peerSend( freezeBlock( action.uid ) ); + break; + case 'CLEAR_SELECTED_BLOCK': + peerSend( clearFrozenBlocks() ); + break; + default: + peerSend( action ); + } + + return returnValue; + }; +} diff --git a/coediting/store/test/index.js b/coediting/store/test/index.js new file mode 100644 index 00000000000000..821f8258ebe76e --- /dev/null +++ b/coediting/store/test/index.js @@ -0,0 +1,264 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { + blocks, + clearFrozenBlocks, + collaborators, + enabled, + freezeBlock, + isBlockFrozenByCollaborator, + isCoeditingEnabled, + toggleCoediting, +} from '../'; + +describe( 'coediting state', () => { + const collaboratorId = 'collaborator-id'; + const uid = 'block-uid'; + + describe( 'blocks reducer', () => { + it( 'should not update the state when no meta provided', () => { + const initialState = deepFreeze( {} ); + const state = blocks( initialState, { + type: 'COEDITING_FREEZE_BLOCK', + uid, + } ); + + expect( state ).toBe( initialState ); + } ); + + it( 'should add a new block frozen by collaborator', () => { + const initialState = deepFreeze( {} ); + const state = blocks( initialState, { + type: 'COEDITING_FREEZE_BLOCK', + uid, + meta: { + collaboratorId, + }, + } ); + + expect( state ).toEqual( { + [ uid ]: collaboratorId, + } ); + } ); + + it( 'should replace a block frozen by collaborator', () => { + const initialState = deepFreeze( { + [ uid ]: collaboratorId, + } ); + const newUid = 'new-uid'; + const state = blocks( initialState, { + type: 'COEDITING_FREEZE_BLOCK', + uid: newUid, + meta: { + collaboratorId, + }, + } ); + + expect( state ).toEqual( { + [ newUid ]: collaboratorId, + } ); + } ); + + it( 'should not update the state when a block was already frozen by the same collaborator', () => { + const initialState = deepFreeze( { + [ uid ]: collaboratorId, + } ); + const state = blocks( initialState, { + type: 'COEDITING_FREEZE_BLOCK', + uid, + meta: { + collaboratorId, + }, + } ); + + expect( state ).toBe( initialState ); + } ); + + it( 'should not update the state when no frozen blocks for collaborator', () => { + const initialState = deepFreeze( {} ); + const state = blocks( initialState, { + type: 'COEDITING_BLOCKS_UNFREEZE', + meta: { + collaboratorId, + }, + } ); + + expect( state ).toBe( initialState ); + } ); + + it( 'should remove the block frozen by collaborator', () => { + const initialState = deepFreeze( { + [ uid ]: collaboratorId, + } ); + const state = blocks( initialState, { + type: 'COEDITING_BLOCKS_UNFREEZE', + meta: { + collaboratorId, + }, + } ); + + expect( state ).toEqual( {} ); + } ); + } ); + + describe( 'collaborators reducer', () => { + const color = 'green'; + const userId = 123; + it( 'should add new collaborator', () => { + const initialState = deepFreeze( {} ); + const state = collaborators( initialState, { + type: 'COEDITING_COLLABORATOR_ADD', + collaboratorId, + userId, + } ); + + expect( state ).toEqual( { + [ collaboratorId ]: { + color: expect.any( String ), + name: `User: ${ userId }`, + userId, + }, + } ); + } ); + + it( 'should not update the state when trying to remove an unknown collaborator', () => { + const initialState = deepFreeze( { + [ collaboratorId ]: { + color, + name: `User: ${ userId }`, + userId, + }, + } ); + const unknownId = 321; + const state = collaborators( initialState, { + type: 'COEDITING_COLLABORATOR_REMOVE', + collaboratorId: unknownId, + } ); + + expect( state ).toBe( initialState ); + } ); + + it( 'should remove the existing collaborator', () => { + const initialState = deepFreeze( { + [ collaboratorId ]: { + color, + name: `User: ${ userId }`, + userId, + }, + } ); + const state = collaborators( initialState, { + type: 'COEDITING_COLLABORATOR_REMOVE', + collaboratorId, + } ); + + expect( state ).toEqual( {} ); + } ); + } ); + + describe( 'enabled reducer', () => { + it( 'should update to true when no previous state provided', () => { + const state = enabled( undefined, { + type: 'COEDITING_TOGGLE', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should update to true when the previous state was false', () => { + const state = enabled( false, { + type: 'COEDITING_TOGGLE', + } ); + + expect( state ).toBe( true ); + } ); + + it( 'should update to false when the previous state was true', () => { + const state = enabled( true, { + type: 'COEDITING_TOGGLE', + } ); + + expect( state ).toBe( false ); + } ); + } ); + + describe( 'toggleCoediting action', () => { + it( 'should return the COEDITING_TOGGLE action', () => { + expect( toggleCoediting() ).toEqual( { + type: 'COEDITING_TOGGLE', + } ); + } ); + } ); + + describe( 'freezeBlock action', () => { + it( 'should return the COEDITING_FREEZE_BLOCK action', () => { + expect( freezeBlock( uid ) ).toEqual( { + type: 'COEDITING_FREEZE_BLOCK', + uid, + } ); + } ); + } ); + + describe( 'clearFrozenBlocks action', () => { + it( 'should return the COEDITING_BLOCKS_UNFREEZE action', () => { + expect( clearFrozenBlocks() ).toEqual( { + type: 'COEDITING_BLOCKS_UNFREEZE', + } ); + } ); + } ); + + describe( 'isBlockFrozenByCollaborator', () => { + const getInitialState = localState => deepFreeze( { + coediting: { + blocks: localState, + }, + } ); + + it( 'should return false when no collaborators data', () => { + const state = getInitialState( null ); + + expect( isBlockFrozenByCollaborator( state, uid ) ).toBe( false ); + } ); + + it( 'should return false when block is not frozen by collaborator', () => { + const state = getInitialState( { + [ uid ]: collaboratorId, + } ); + + expect( isBlockFrozenByCollaborator( state, uid ) ).toBe( true ); + } ); + + it( 'should return true when block is frozen by collaborator', () => { + const state = getInitialState( { + unknown: collaboratorId, + } ); + + expect( isBlockFrozenByCollaborator( state, uid ) ).toBe( false ); + } ); + } ); + + describe( 'isCoeditingEnabled', () => { + const getInitialState = localState => deepFreeze( { + coediting: { + enabled: localState, + }, + } ); + + it( 'should return false when coediting is disabled', () => { + const state = getInitialState( false ); + + expect( isCoeditingEnabled( state ) ).toBe( false ); + } ); + + it( 'should return true when coediting is enabled', () => { + const state = getInitialState( true ); + + expect( isCoeditingEnabled( state ) ).toBe( true ); + } ); + } ); +} ); diff --git a/coediting/utils/index.js b/coediting/utils/index.js new file mode 100644 index 00000000000000..7f01fe5ee6a6b4 --- /dev/null +++ b/coediting/utils/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { camelCase, isObject } from 'lodash'; + +export function camelCaseKeysDeep( input ) { + if ( Array.isArray( input ) ) { + return input.map( camelCaseKeysDeep ); + } + + if ( isObject( input ) ) { + return Object.keys( input ).reduce( ( acc, key ) => { + acc[ camelCase( key ) ] = camelCaseKeysDeep( input[ key ] ); + return acc; + }, {} ); + } + + return input; +} diff --git a/coediting/utils/test/index.js b/coediting/utils/test/index.js new file mode 100644 index 00000000000000..2a21bb5d2bc56b --- /dev/null +++ b/coediting/utils/test/index.js @@ -0,0 +1,35 @@ +/** + * Internal dependencies + */ +import { camelCaseKeysDeep } from '../'; + +describe( 'camelCaseKeysDeep', () => { + it( 'should do nothing with the primitive types', () => { + expect( camelCaseKeysDeep( null ) ).toBe( null ); + expect( camelCaseKeysDeep( true ) ).toBe( true ); + expect( camelCaseKeysDeep( 1 ) ).toBe( 1 ); + expect( camelCaseKeysDeep( 'Name' ) ).toBe( 'Name' ); + } ); + + it( 'should do nothing with an array containing the primitive values', () => { + expect( camelCaseKeysDeep( [ null, true, 1, 'Name' ] ) ).toEqual( [ null, true, 1, 'Name' ] ); + } ); + + it( 'should make keys camel case for deeply nested objects', () => { + const input = [ + { + type: 'initial', + peer_id: 1, + peer_name: 'Name', + }, + ]; + const output = [ + { + type: 'initial', + peerId: 1, + peerName: 'Name', + }, + ]; + expect( camelCaseKeysDeep( input ) ).toEqual( output ); + } ); +} ); diff --git a/editor/actions.js b/editor/actions.js index a87e0fe76e0ed4..23027df7e93a74 100644 --- a/editor/actions.js +++ b/editor/actions.js @@ -518,36 +518,6 @@ export function toggleFeature( feature ) { }; } -/** - * Returns an action object and blockID where to show the UI. - * - * @param {Object} grtcProps contains {peerName, peerID, peerColor} - * @param {String} blocksByUid string - * - * @return {Object} Action object - */ -export function collaborationState( grtcProps, blocksByUid ) { - return { - type: 'COLLABORATION_STATE', - blocksByUid, - ...grtcProps, - }; -} - -/** - * Returns an action object used to enable/disable a collaborative session - * - * @param {Boolean} active true or false for collaboration - * - * @return {Object} Action object - */ -export function collaborationMode( active ) { - return { - type: 'COLLABORATION_MODE', - active, - }; -} - export const createSuccessNotice = partial( createNotice, 'success' ); export const createInfoNotice = partial( createNotice, 'info' ); export const createErrorNotice = partial( createNotice, 'error' ); diff --git a/editor/assets/stylesheets/_variables.scss b/editor/assets/stylesheets/_variables.scss index b83dbdd7e15ca1..1035fc4bb351ea 100644 --- a/editor/assets/stylesheets/_variables.scss +++ b/editor/assets/stylesheets/_variables.scss @@ -37,13 +37,6 @@ $inserter-tabs-height: 36px; $block-toolbar-height: 37px; $sidebar-width: 280px; -/* Collaboration */ -$collab-red: #dc3232; -$collab-orange: #f56e28; -$collab-yellow: #ffb900; -$collab-green: #46b450; -$collab-purple: #826eb4; - // Blocks $block-padding: 14px; $block-mover-margin: 18px; diff --git a/editor/components/block-list/block.js b/editor/components/block-list/block.js index 082692d0ddfd79..3a7818d4e2feff 100644 --- a/editor/components/block-list/block.js +++ b/editor/components/block-list/block.js @@ -34,7 +34,6 @@ import BlockContextualToolbar from './block-contextual-toolbar'; import BlockMultiControls from './multi-controls'; import { clearSelectedBlock, - collaborationState, editPost, focusBlock, insertBlocks, @@ -54,7 +53,6 @@ import { getBlockIndex, getEditedPostAttribute, getNextBlock, - getPeerData, getPreviousBlock, isBlockHovered, isBlockMultiSelected, @@ -275,7 +273,7 @@ export class BlockListBlock extends Component { onFocus( event ) { if ( event.target === this.node ) { - this.props.onSelect( this.props.peerData ); + this.props.onSelect(); } } @@ -293,7 +291,7 @@ export class BlockListBlock extends Component { } } else { this.props.onSelectionStart( this.props.uid ); - this.props.onSelect( this.props.peerData ); + this.props.onSelect(); } } @@ -352,9 +350,7 @@ export class BlockListBlock extends Component { render() { const { block, order, mode, showContextualToolbar, isLocked } = this.props; const { name: blockName, isValid } = block; - const { peerName, peerColor, peerShowStyle } = this.props.peerData.peerMetaData; - const peerColorClass = 'collab-' + peerColor; - const blockType = getBlockType( block.name ); + const blockType = getBlockType( blockName ); // translators: %s: Type of block (i.e. Text, Image etc) const blockLabel = sprintf( __( 'Block: %s' ), blockType.title ); // The block as rendered in the editor is composed of general block UI @@ -362,7 +358,7 @@ export class BlockListBlock extends Component { // Generate the wrapper class names handling the different states of the block. const { isHovered, isSelected, isMultiSelected, isFirstMultiSelected, focus } = this.props; - let showUI = isSelected && ( ! this.props.isTyping || ( focus && focus.collapsed === false ) ); + const showUI = isSelected && ( ! this.props.isTyping || ( focus && focus.collapsed === false ) ); const { error } = this.state; const wrapperClassName = classnames( 'editor-block-list__block', { 'has-warning': ! isValid || !! error, @@ -370,8 +366,6 @@ export class BlockListBlock extends Component { 'is-multi-selected': isMultiSelected, 'is-hovered': isHovered, 'is-reusable': isReusableBlock( blockType ), - 'is-collaboration': true, - [ peerColorClass ]: true, } ); const { onMouseLeave, onFocus, onReplace } = this.props; @@ -382,11 +376,6 @@ export class BlockListBlock extends Component { wrapperProps = blockType.getEditWrapperProps( block.attributes ); } - // Don't show controls when collaboration is enabled. - if ( peerShowStyle ) { - showUI = false; - } - // Disable reason: Each block can be selected by clicking on it /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return ( @@ -402,7 +391,6 @@ export class BlockListBlock extends Component { { ...wrapperProps } > - { peerName } { ( showUI || isHovered ) && } { ( showUI || isHovered ) && } { showUI && isValid && showContextualToolbar && } @@ -469,7 +457,6 @@ const mapStateToProps = ( state, { uid } ) => ( { meta: getEditedPostAttribute( state, 'meta' ), mode: getBlockMode( state, uid ), isSelectionEnabled: isSelectionEnabled( state ), - peerData: getPeerData( state, uid ), } ); const mapDispatchToProps = ( dispatch, ownProps ) => ( { @@ -477,9 +464,8 @@ const mapDispatchToProps = ( dispatch, ownProps ) => ( { dispatch( updateBlockAttributes( uid, attributes ) ); }, - onSelect( peerData ) { + onSelect() { dispatch( selectBlock( ownProps.uid ) ); - dispatch( collaborationState( peerData.grtcProps, ownProps.uid ) ); }, onDeselect() { dispatch( clearSelectedBlock() ); diff --git a/editor/components/block-list/style.scss b/editor/components/block-list/style.scss index 2ec1bf55a6ac64..0015a3177b73ed 100644 --- a/editor/components/block-list/style.scss +++ b/editor/components/block-list/style.scss @@ -94,76 +94,6 @@ background: $blue-medium-highlight; } - &.is-collaboration { - .collaboration-legend { - float: right; - margin-top: -14px; - margin-right: -14px; - color: #fff; - font-size: 12px; - } - - &[class*="collab-"] { - pointer-events: none; - opacity: 0.8; - } - - &.collab-null { - pointer-events: all; - opacity: 1; - } - - &.collab-red { - &:before { - outline: 2px solid $collab-red; - transition: 0.2s outline; - } - .collaboration-legend { - background: $collab-red; - } - } - - &.collab-orange { - &:before { - outline: 2px solid $collab-orange; - transition: 0.2s outline; - } - .collaboration-legend { - background: $collab-orange; - } - } - - &.collab-yellow { - &:before { - outline: 2px solid $collab-yellow; - transition: 0.2s outline; - } - .collaboration-legend { - background: $collab-yellow; - } - } - - &.collab-green { - &:before { - outline: 2px solid $collab-green; - transition: 0.2s outline; - } - .collaboration-legend { - background: $collab-green; - } - } - - &.collab-purple { - &:before { - outline: 2px solid $collab-purple; - transition: 0.2s outline; - } - .collaboration-legend { - background: $collab-purple; - } - } - } - // selection style for multiple blocks &.is-multi-selected *::selection { background: transparent; diff --git a/editor/edit-post/sidebar/coediting-panel/index.js b/editor/edit-post/sidebar/coediting-panel/index.js new file mode 100644 index 00000000000000..86033c2d542140 --- /dev/null +++ b/editor/edit-post/sidebar/coediting-panel/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { connect } from 'react-redux'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { PanelBody, PanelRow, FormToggle } from '@wordpress/components'; + +/** + * Internal Dependencies + */ +import { isCoeditingEnabled, toggleCoediting } from 'coediting/store'; + +function CoeditingPanel( { enabled = false, ...props } ) { + const handleToggle = () => props.toggleCoediting(); + + return ( + + + + + + + ); +} + +export default connect( + ( state ) => { + return { + enabled: isCoeditingEnabled( state ), + }; + }, + { toggleCoediting } +)( CoeditingPanel ); diff --git a/editor/edit-post/sidebar/collaboration/index.js b/editor/edit-post/sidebar/collaboration/index.js deleted file mode 100644 index 30151555e358b9..00000000000000 --- a/editor/edit-post/sidebar/collaboration/index.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * External dependencies - */ -import { connect } from 'react-redux'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { PanelBody, PanelRow, FormToggle } from '@wordpress/components'; - -/** - * Internal Dependencies - */ -import { collaborationMode } from '../../../actions'; - -function CollaborationPanel( { active = false, ...props } ) { - const changeMode = () => props.collaborationMode( ! active ); - - return ( - - - - - - - ); -} - -export default connect( - ( state ) => { - return { - active: state.collaborationMode.active, - }; - }, - { collaborationMode } -)( CollaborationPanel ); diff --git a/editor/edit-post/sidebar/post-settings/index.js b/editor/edit-post/sidebar/post-settings/index.js index 64b62989d64a07..d664540e2347f1 100644 --- a/editor/edit-post/sidebar/post-settings/index.js +++ b/editor/edit-post/sidebar/post-settings/index.js @@ -16,7 +16,7 @@ import LastRevision from '../last-revision'; import PageAttributes from '../page-attributes'; import DocumentOutlinePanel from '../document-outline-panel'; import { MetaBoxes } from '../../../components'; -import CollaborationPanel from '../collaboration'; +import CoeditingPanel from '../coediting-panel'; const panel = ( @@ -28,7 +28,7 @@ const panel = ( - + ); diff --git a/editor/index.js b/editor/index.js index 3d99d7a3240fa2..e71f502f29cf3e 100644 --- a/editor/index.js +++ b/editor/index.js @@ -14,6 +14,7 @@ import { settings as dateSettings } from '@wordpress/date'; * Internal dependencies */ import './assets/stylesheets/main.scss'; +import '../coediting/hooks'; import Layout from './edit-post/layout'; import { EditorProvider, ErrorBoundary } from './components'; import { initializeMetaBoxState } from './actions'; diff --git a/editor/selectors.js b/editor/selectors.js index 2138b507a5c225..50ee60e188a4dd 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -1145,49 +1145,5 @@ export function isPublishingPost( state ) { // Consider as publishing when current post prior to request was not // considered published - return ! ! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest ); -} - -/** - * Returns the peerData Object - * - * @param {Object} state Global application state - * @param {Object} uid current props uid - * - * @return {Object} peerMataData {Name, Css style for peerColor, peerID}, grtcProps - */ -export function getPeerData( state, uid ) { - const { peerID, peerName, peerColor, blocksByUid } = state.collaborationMode; - const grtcProps = state.collaborationLocalData; - let peerMetaData = { - peerID: null, - peerName: null, - peerColor: null, - peerShowStyle: false, - }; - - // Show UI if peerID doesn't match and you get state from other peer. - if ( peerColor && peerID && peerName && peerID !== grtcProps.peerID && blocksByUid === uid ) { - peerMetaData = { - peerID, - peerName, - peerColor, - peerShowStyle: true, - blockID: blocksByUid, - }; - grtcProps.lastPeerData = peerMetaData; - } - - // This is to avoid unsetting current another peer border, This is basically storing last block peer data. - if ( grtcProps.lastPeerData && peerID === grtcProps.peerID && grtcProps.lastPeerData.blockID === uid ) { - return { - peerMetaData: grtcProps.lastPeerData, - grtcProps, - }; - } - - return { - peerMetaData, - grtcProps, - }; + return !! stateBeforeRequest && ! isCurrentPostPublished( stateBeforeRequest ); } diff --git a/editor/store/reducer.js b/editor/store/reducer.js index dab98acfb232a3..900ab5b22d7f7d 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -29,6 +29,7 @@ import { getBlockTypes, getBlockType } from '@wordpress/blocks'; /** * Internal dependencies */ +import { reducer as coediting } from 'coediting/store'; import withHistory from '../utils/with-history'; import withChangeDetection from '../utils/with-change-detection'; import { PREFERENCES_DEFAULTS } from './defaults'; @@ -38,11 +39,6 @@ import { PREFERENCES_DEFAULTS } from './defaults'; */ const MAX_RECENT_BLOCKS = 8; -/** -* Collaboration Dependencies -*/ -import GRTC from '../grtc/app'; - /** * Returns a post attribute value, flattening nested rendered content using its * raw value in place of its original object form. @@ -411,6 +407,7 @@ export function blockSelection( state = { end: action.uid, focus: action.config || {}, }; + // TODO: Trigger UPDATE_FOCUS instead to avoid focus change on collaborator side case 'INSERT_BLOCKS': return { ...state, @@ -419,6 +416,7 @@ export function blockSelection( state = { focus: {}, isMultiSelecting: false, }; + // TODO: Trigger UPDATE_FOCUS instead to avoid focus change on collaborator side case 'REPLACE_BLOCKS': if ( ! action.blocks || ! action.blocks.length || action.uids.indexOf( state.start ) === -1 ) { return state; @@ -788,153 +786,6 @@ export const reusableBlocks = combineReducers( { }, } ); -export function collaborationLocalData( state = {}, action ) { - switch ( action.type ) { - case 'COLLABORATION_LOCAL_DATA': - return { - ...state, - peerColor: action.peerColor, - peerID: action.peerID, - lastPeerData: action.lastPeerData, - peerName: action.peerName, - }; - } - return state; -} - -export function collaborationMode( state = {}, action ) { - switch ( action.type ) { - case 'COLLABORATION_MODE': - return { - ...state, - active: action.active, - }; - - case 'COLLABORATION_STATE': - return { - ...state, - blocksByUid: action.blocksByUid, - peerName: action.peerName, - peerColor: action.peerColor, - peerID: action.peerID, - }; - } - return state; -} - -export function grtcMiddleware( { dispatch } ) { - let grtc = {}; - let grtcID = window.location.hash.slice( 1, window.location.hash.length ); - let manualGRTC = true; - - const grtcAPI = window.location.origin + '/wp-json/collaborate'; - const peerID = GRTC.uuid(); - - const grtcProps = { - peerColor: GRTC.getColor(), - peerID, - lastPeerData: null, - peerName: null, - }; - - /** - * @return {Promise} promise object for username fetch. - * Resolves promise if username fetch failed. - */ - function getUsername() { - return new Promise( ( resolve, reject ) => { - jQuery.get( grtcAPI + '/username', resolve ).fail( reject ); - } ); - } - - /** - * @param {Boolean} active is collaboration mode - * @param {Object} reduxStore for reference in current scope. - * Initializes itself if collaboration mode is enabled. - * else Destory the P2P Data channels. - */ - function checkMode( active ) { - if ( active ) { - if ( window.location.hash.length === 0 ) { - grtcID = GRTC.uuid(); - window.history.pushState( '', '', '#' + grtcID ); - startMode( grtcID, grtcAPI ); - } else { - startMode( grtcID, grtcAPI ); - } - } else if ( ! grtc.isConnected ) { - grtc.peer.destroy(); - } - } - - /** - * Start the grtc module and enable its API. - * @param {string} id the unique doc id - * @param {string} url url to which module will interact. - */ - function startMode( id, url ) { - getUsername().then( ( username ) => { - grtcProps.peerName = username; - dispatch( { - type: 'COLLABORATION_LOCAL_DATA', - ...grtcProps, - } ); - grtc = new GRTC( id, url, username ); - grtc.on( 'peerData', onReceivedAction ); - // Temporary change for testing, Not using alert. Need UI. - grtc.on( 'peerConnected', function() { - alert( 'Peer connected' ); - } ); - } ); - } - - /** - * @param {Object} action is redux action - * send if grtc is connected & peerID is undefined - * to stop infinite loop. - */ - function onAction( action ) { - if ( action.type === 'TOGGLE_BLOCK_SELECTED' || action.type === 'UPDATE_FOCUS' || action.type === 'COLLABORATION_LOCAL_DATA' ) { - return; - } - - if ( grtc.isConnected && typeof action.peerID === 'undefined' ) { - action.peerID = peerID; - grtc.send( action ); - } else if ( grtc.isConnected && action.type === 'COLLABORATION_STATE' ) { - grtc.send( action ); - } - } - - /** - * @param {Object} action received from other peer. - * Dispatch the action using dispatch. - */ - function onReceivedAction( action ) { - if ( typeof action.peerID !== 'undefined' && action.peerID !== peerID ) { - dispatch( action ); - } - } - - return next => action => { - if ( action.type === 'COLLABORATION_MODE' ) { - checkMode( action.active ); - manualGRTC = false; - } else if ( window.location.hash.length !== 0 && manualGRTC ) { - // if url is shared enable the collaboration mode automatically. - manualGRTC = false; - dispatch( { - type: 'COLLABORATION_MODE', - active: true, - mode: 'manual', - } ); - } else { - onAction( action ); - } - return next( action ); - }; -} - export default optimist( combineReducers( { editor, currentPost, @@ -950,6 +801,5 @@ export default optimist( combineReducers( { metaBoxes, browser, reusableBlocks, - collaborationMode, - collaborationLocalData, + coediting, } ) ); diff --git a/editor/store/store.js b/editor/store/store.js index 3393f9cbd8a620..18ff7688704bbd 100644 --- a/editor/store/store.js +++ b/editor/store/store.js @@ -15,7 +15,7 @@ import { dispatch, getState, subscribe } from '@wordpress/data'; */ import { mobileMiddleware } from '../utils/mobile'; import effects from './effects'; -import { grtcMiddleware } from './reducer'; +import coeditingMiddleware from 'coediting/store/middleware'; /** * Applies the custom middlewares used specifically in the editor module @@ -27,7 +27,7 @@ import { grtcMiddleware } from './reducer'; function applyMiddlewaresAndRestrictState() { const middlewares = [ mobileMiddleware, - grtcMiddleware, + coeditingMiddleware, refx( effects ), multi, ]; diff --git a/grtc/README.md b/grtc/README.md deleted file mode 100644 index 88a21505f87dae..00000000000000 --- a/grtc/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# Gutenberg Collaborative Editing Plugin -## Based on WebRTC ---- - -### Installation -``` -npm install -``` - - -### Building -``` -npm run build -``` - -### Local Server -``` -npm start -``` - - -### Testing -``` -npm test -``` - ---- - -### Starting App -**Peer starting collaboration has to generate a uuid using** -``` -var grtcID = GRTC.uuid(); // static function -``` - -**After that pass that to GRTC module** - -``` -window.history.pushState('', '', '#' + grtcID); // optional to add to url so can share with others. -var grtc = new GRTC(grtcID, window.location.origin, true); // 3rd param is transport layer for encrypted session. -``` - -**Peer not starting collaboration has to join and get that grtcID somehow possibly by sharing url** - -___ - -## API - -**Events** - -* 'peerFound' -* Checked via long polling to /get/grtcID route to server -``` -grtc.on('peerFound', function(peer){ - // peer => peer signal used for connection establishment -}); -``` - -* 'peerSignal' -* Received from other peer as offer. -``` -grtc.on('peerSignal', function(signal){ - // signal => signal that is received from another peer. -}); -``` - -* 'peerConnected' -* Emitted after peerSignal and connection is established. -``` -grtc.on('peerConnected', function(){ - // peer is connected. -}); -``` - -* 'peerData' -* when data is received. -``` -grtc.on('peerData', function(data){ - //data is always json stringified -}); -``` - -**Miscellaneous Events** -* 'publicKey' -* when other peer publicKey is fetched. -``` -grtc.on('publicKey', function(pubKey){ - //pubKey => encrypt shared token using this and send that to peer. -}); -``` - -* 'peerSecret' -* when you received secret encrypted using your public key -``` -grtc.on('peerSecret', function(secret){ - //secret => its decrypted using your private key. -}); -``` - -* 'secretAck' -* this is ack when peer reply to initiator that secret is received. -``` -grtc.on('secretAck', function(){ - -}); -``` - -* 'transport' -* this is emitted on both initiator and peers that transport layer is ready to use. -``` -grtc.on('transport', function(){ - // you can call grtc.secureSend('something') function -}); -``` - - -## Data Format - -**Payload should always be JSON object which can be sent directly using grtc.send without stringify** - - diff --git a/grtc/app.js b/grtc/app.js deleted file mode 100644 index 8c4f8171bc1e2f..00000000000000 --- a/grtc/app.js +++ /dev/null @@ -1,471 +0,0 @@ -'use strict'; - -import Peer from 'simple-peer'; -import uuidv4 from 'uuid/v4'; -import uuidv1 from 'uuid/v1'; -import { EventEmitter } from 'events'; - -import crypto from './crypto'; - -/** - * Signal class that uses promise not event emitters and can be used standalone. - * getSignal and updateSignal lets you update and fetch data regarding the shared key. - */ -class Signal { - /** - * @param {string} url url to which signal should go. - * @param {string} grtcID global id shared by url - * @param {string} peerID global peerID that maps to username. - * @param {string} peerName global username that maps to peerID. - * @param {object} signalID signal generated by peer - * url is base url of page. - * grtcID is collaborate param from url. - * paramID is changing peerID on refresh or every new grtc instance. - * signalID is peer signal used to traverse and connect P2P. - */ - constructor( url, grtcID, peerID, peerName, signalID ) { - const self = this; - self.url = url; - self.grtcID = grtcID; - self.peerID = peerID; - self.signalID = signalID; - self.peerName = peerName; - } - - /** - * Clear the key forcefully in kv. - * @return {promise} promise object - */ - clearSignal() { - const self = this; - return new Promise( ( resolve, reject ) => { - const data = { peerID: self.peerID, peerName: self.peerName, type: 'initial', signal: self.signalID }; - jQuery.post( self.url + '/remove', { [ self.grtcID ]: window.btoa( JSON.stringify( data ) ) }, ( resp ) => { - resolve( resp ); - } ).fail( ( e ) => { - reject( e ); - } ); - } ); - } - - /** - * getSignal is called periodically in order to fetch the updated signal. - * @return {promise} promise object - */ - getSignal() { - const self = this; - return new Promise( ( resolve, reject ) => { - jQuery.get( self.url + '/get/' + self.grtcID, ( resp ) => { - resolve( resp ); - } ).fail( ( e ) => { - reject( e ); - } ); - } ); - } - - /** - * Updates the server route so that peers can get the data. - * @return {promise} promise object - */ - updateSignal() { - const self = this; - return new Promise( ( resolve, reject ) => { - const data = { - peerID: self.peerID, - signal: self.signalID, - peerName: self.peerName, - type: 'register', - }; - jQuery.post( self.url + '/set', { [ self.grtcID ]: window.btoa( JSON.stringify( data ) ) }, ( resp ) => { - resolve( resp ); - } ).fail( ( e) => { - reject( e ); - } ); - } ); - } -} - -/** - * TransportLayer use a single key and provide the abstractions - * to send data encrypted and receieve decrypted data using the key. - */ -class TransportLayer { - constructor( key ) { - const self = this; - try { - self.key = window.atob( key.replace( /\0/g, '' ) ); // remove null chars. - } catch ( e ) { - self.key = key; - } - } - - /** - * @param {string} data raw data - * encrypts using created key with pkcs5 module. - */ - encrypt( data ) { - const iv = crypto.forge.random.getBytesSync( 16 ); - const cipher = crypto.forge.cipher.createCipher( 'AES-CBC', this.key ); - - cipher.start( { iv } ); - cipher.update( crypto.forge.util.createBuffer( data ) ); - cipher.finish(); - - const encrypted = cipher.output; - const obj = { iv: crypto.forge.util.bytesToHex( iv ), encrypted: crypto.forge.util.bytesToHex( encrypted ) }; - - return obj; - } - - /** - * @param {string} encrypted data for decryption - * @return {string} decrypted data. - * decrypts using object which has IV. - */ - decrypt( encrypted ) { - const iv = crypto.forge.util.createBuffer(); - const data = crypto.forge.util.createBuffer(); - iv.putBytes( crypto.forge.util.hexToBytes( encrypted.iv ) ); - data.putBytes( crypto.forge.util.hexToBytes( encrypted.encrypted ) ); - - const decipher = crypto.forge.cipher.createDecipher( 'AES-CBC', this.key ); - decipher.start( { iv } ); - decipher.update( data ); - decipher.finish(); - return decipher.output; - } -} - -/** - * Main GRTC module - */ - -class GRTC extends EventEmitter { - /** - * @param {string} grtcID global id representing document. - * @param {string} url url of signal routes. - * @param {string} peerName name of user/ wordpress username. - * @param {bool} useTransport default false for encrypted session. - * uuid is uniquely generated id for collaboration to happen - */ - constructor( grtcID, url, peerName, useTransport ) { - super(); - const self = this; - self.peer = null; - self.peerSignal = null; - self.signalInstance = null; - self.url = url; - self.grtcID = grtcID; - self.peerID = GRTC.uuid(); - self.peerName = peerName; - self.otherPeers = new Set(); - self.listenSignalTimer = 0; - self.listenSignalCount = 0; - self.isConnected = false; - self.keys = []; - self.useTransport = useTransport; - self.init(); - } - - /** - * @param {string} queryString parsed from url. - * @return {string} uuid from url. - * Returns the stripped out queryString that is used by peers. - */ - static queryParameter( queryString ) { - const queryIndex = queryString.indexOf( 'collaborate' ); - return queryString.substring( queryIndex, queryIndex + 48 ).split( '=' ).pop(); - } - - /** - * Random color generate for peer. - * @return {string} random color. - */ - static randomColor() { - const letters = '0123456789ABCDEF'; - let color = '#'; - for ( let i = 0; i < 6; i++ ) { - color += letters[ Math.floor( Math.random() * 16 ) ]; - } - return color; - } - - /** - * Returns random color from a list of arrays. - * - * @return {string} color from array. - */ - static getColor() { - const colors = [ 'red', 'purple', 'orange', 'yellow', 'green' ]; - return colors[ Math.floor(Math.random() * colors.length) ]; - } - - /** - * Generates uuid which is used for url unique hash. - * @return {string} uuid. - */ - static uuid() { - return uuidv1(); - } - - /** - * Used for AES encryption ( symmetric ) afterwards. - * @return {string} aes key base64 converted. - */ - static secret() { - return window.btoa( crypto.forge.pkcs5.pbkdf2( uuidv4(), crypto.forge.random.getBytesSync( 128 ), 3, 16 ) ); - } - - /** - * Set difference API for calculating difference. Note: setA - setB != setB - setA - * @param {set} setA first set - * @param {set} setB second set - * @return {set} difference of two sets - */ - setDifference( setA, setB ) { - const difference = new Set( setA ); - for ( const elem of setB ) { - difference.delete( elem ); - } - return difference; - } - - /** - * Check if you are initiator or not. - */ - isInitiator() { - const self = this; - const data = { peerID: self.peerID, type: 'initial', peerName: self.peerName }; - jQuery.post( self.url + '/set', { [ self.grtcID ] : window.btoa( JSON.stringify( data ) ) }, ( resp ) => { - self.emit( 'initiator', resp ); - } ).fail( () => { - self.emit( 'initiator', false ); - } ); - } - - /** - * Listens for signals after finding initiator. - */ - listenSignal() { - const self = this; - self.listenSignalRoutine(); - self.listenSignalTimer = setInterval( () => { - self.listenSignalCount++; - self.listenSignalRoutine(); - }, 3000 ); - } - - /** - * signal routine to continue in loop - */ - listenSignalRoutine() { - const self = this; - self.signalInstance.getSignal().then( ( resp ) => { - resp.forEach( ( peer ) => { - if ( peer.peerID !== self.peerID && ! self.otherPeers.has( peer.peerID ) && peer.signal ) { - self.otherPeers.add( peer.peerID ); - self.emit( 'peerFound', peer ); - } - } ); - } ); - } - - /** - * Data handler for received data. - * Monitors data received, publicKey and sharedkey for authentication. - * @param {string} data encrypted data from channel. - */ - dataHandler( data ) { - const self = this; - const parsedData = JSON.parse( data ); - - if ( typeof parsedData !== 'object' ) { - self.emit( 'peerData', parsedData ); - return; - } - - if ( 'encrypted' in parsedData ) { - self.emit( 'peerEncryptedData', parsedData ); - } - - if ( 'publicKey' in parsedData ) { - self.emit( 'publicKey', parsedData.publicKey ); - } else if ( 'secret' in parsedData ) { - self.emit( 'secret', parsedData.secret ); - } else if ( 'secretAck' in parsedData ) { - self.emit( 'transport', parsedData ); - self.send( { secretAckConfirmed: true } ); - } else if ( 'secretAckConfirmed' in parsedData ) { - self.emit( 'transport' ); - } else { - self.emit( 'peerData', parsedData ); - } - } - - /** - * peerHandler returns peer and signalReceived. - * signal received is immediate if initiator is true. - * signal received is not present if initiator is false it waits for initiator signal. - * @return {promise} promise object - */ - peerHandler() { - const self = this; - return new Promise( ( resolve, reject ) => { - self.peer = new Peer( { - initiator: self.initiator === true, - trickle: false, - } ); - - self.peer.on( 'signal', ( peerSignal ) => { - self.peerSignal = peerSignal; - resolve(); - } ); - - self.on( 'peerFound', ( peer ) => { - self.peer.signal( peer.signal ); - } ); - - self.peer.on( 'signal', ( data ) => { - self.emit( 'peerSignal', data ); - } ); - - self.peer.on( 'connect', () => { - self.emit( 'peerConnected' ); - self.isConnected = true; - } ); - - self.peer.on( 'data', ( data ) => { - self.dataHandler( data ); - } ); - - self.peer.on( 'close', ( peer ) => { - self.isConnected = false; - self.emit( 'peerClosed', peer ); - clearInterval( self.listenSignalTimer ); - delete self._events.initiator; - delete self._events.peerFound; - self.init(); - } ); - - /** - * Override peer send to automatically convert json. - * @param {string} data json wrapper - */ - self.send = function( data ) { - self.peer.send( JSON.stringify( data ) ); - }; - } ); - } - - /** - * Generates a public/private key pair with 1024 bit RSA. - * Send public key to other peers. - */ - securityHandler() { - const self = this; - self.on( 'peerConnected', () => { - crypto.generateKeys().then( ( keys ) => { - self.keys = keys; - const payload = { - publicKey: keys.publicKey, - }; - /** - * Send public key to initiator only. - */ - if ( self.initiator === false ) { - self.send( payload ); - } - } ); - } ); - - /** - * Listened on intiator. - */ - self.on( 'publicKey', ( publicKey ) => { - const encryptedKey = { secret: crypto.encrypt( self.sharedSecret, publicKey ) }; - self.send( encryptedKey ); - } ); - - /** - * Listened on other peers. - * Ack the initiator that secret is received and is converted from base64 to original string. - */ - self.on( 'secret', ( sharedKey ) => { - self.sharedSecret = crypto.decrypt( sharedKey, self.keys.privateKey ); - self.emit( 'peerSecret', self.sharedSecret ); - self.send( { secretAck: true } ); - } ); - } - - /** - * Start the transport layer using TransportLayer Class. - */ - startTransportLayer() { - const self = this; - self.transportLayer = new TransportLayer( self.sharedSecret ); - - /** - * Create new send API. - * @param {string} data decrypted data - */ - self.secureSend = function( data ) { - const newData = self.transportLayer.encrypt( JSON.stringify( data ) ); - self.send( newData ); - }; - - /** - * Listen of data received which is encrypted. - */ - self.on( 'peerEncryptedData', ( encrypted ) => { - const decryptedData = self.transportLayer.decrypt( encrypted ); - self.emit( 'peerSecureData', decryptedData ); - } ); - } - - /** - * Called by contructor and main entry point of app. - */ - init() { - const self = this; - self.isInitiator(); - - /** - * Server decides if you get to initiate or not. - * Because of persistance independent of peers. - */ - self.on( 'initiator', ( resp ) => { - /** - * Shared secret is generated by initiator and passed to others. - * else if not initiator start listening for signals. - */ - self.initiator = resp.initiator; - if ( self.initiator ) { - self.sharedSecret = GRTC.secret(); - } else { - self.signalInstance = new Signal( self.url, self.grtcID, self.peerID, self.peerName, self.peerSignal ); - self.listenSignal(); - } - - /** - * Will be resolved after initiator is set only. - */ - self.peerHandler().then( () => { - self.signalInstance = new Signal( self.url, self.grtcID, self.peerID, self.peerName, self.peerSignal ); - self.signalInstance.updateSignal().then( () => { - self.listenSignal(); - } ); - } ); - } ); - - /** - * Use transport layer for more security. - * Default is false. - */ - if ( self.useTransport ) { - self.securityHandler(); - self.on( 'transport', self.startTransportLayer ); - } - } -} - -export default GRTC; diff --git a/grtc/crypto.js b/grtc/crypto.js deleted file mode 100644 index 73192dab818191..00000000000000 --- a/grtc/crypto.js +++ /dev/null @@ -1,32 +0,0 @@ -const forge = require( 'node-forge' ); -const rsa = forge.pki.rsa; - -const crypto = { - forge, - generateKeys: () => { - return new Promise( ( resolve, reject ) => { - // generate an RSA key pair asynchronously (uses web workers if available) - // use workers: -1 to run a fast core estimator to optimize # of workers - rsa.generateKeyPair( { bits: 1024, workers: 2 }, ( err, keypair ) => { - if ( err ) { - return reject( err ); - } - - const newKeys = { - publicKey: forge.pki.publicKeyToPem( keypair.publicKey ), - privateKey: forge.pki.privateKeyToPem( keypair.privateKey ), - }; - - resolve( newKeys ); - } ); - } ); - }, - encrypt: ( msg, pubKey ) => { - return rsa.encrypt( msg, forge.pki.publicKeyFromPem( pubKey ), true ); - }, - decrypt: ( msg, priKey ) => { - return rsa.decrypt( msg, forge.pki.privateKeyFromPem( priKey ), false, false ); - }, -}; - -export default crypto; diff --git a/lib/coediting.php b/lib/coediting.php new file mode 100644 index 00000000000000..d7c69ee61a6e03 --- /dev/null +++ b/lib/coediting.php @@ -0,0 +1,194 @@ +\w+.*)', array( + 'methods' => 'GET', + 'callback' => 'coediting_get', + ) ); + + register_rest_route( 'coediting', '/set', array( + 'methods' => 'POST', + 'callback' => 'coediting_set', + ) ); + + register_rest_route( 'coediting', '/remove', array( + 'methods' => 'POST', + 'callback' => 'coediting_remove', + ) ); +} + +/** + * Gets peer data from the request. + * + * @since 1.3.0 + * + * @param string $request_value Value encoded from request. + * + * @return array [ type, peer_id, user_id, signal ] + */ +function get_peer_data( $request_value ) { + $decoded_data = json_decode( base64_decode( $request_value, true ) ); + + $type = isset( $decoded_data->type ) ? $decoded_data->type : ''; + $peer_id = isset( $decoded_data->peer_id ) ? $decoded_data->peer_id : ''; + $signal = isset( $decoded_data->signal ) ? $decoded_data->signal : ''; + $user_id = isset( $decoded_data->user_id ) ? $decoded_data->user_id : ''; + + return array( $type, $peer_id, $user_id, $signal ); +} + + +/** + * Checks if an array has same peer data already. + * + * @since 1.3.0 + * + * @param array $kv_key Data fetched from db. + * @param string $user_id User identifier. + * @param string $peer_id Peer identifier. + * + * @return array [ boolean, array, array ] + */ +function peer_exist_update( $kv_key, $user_id, $peer_id ) { + for ( $i = 0; $i < count( $kv_key ); $i ++ ) { + if ( $kv_key[ $i ]['user_id'] == $user_id ) { + $kv_key[ $i ]['peer_id'] = $peer_id; + $kv_key[ $i ]['signal'] = false; + + return array( true, $kv_key[ $i ], $kv_key ); + } + } + + return array( false, array(), array() ); +} + +/** + * Gets the data from the key value store. + * + * @since 1.3.0 + * + * @param object $request_data Request data. + * + * @return string Data fetched from the key value store. + */ +function coediting_get( $request_data ) { + return get_option( $request_data['key'] ); +} + +/** + * Sets the data from route to signal's key value store. + * + * @since 1.3.0 + * + * @param object $request_data Request data. + * + * @return string Data which is pushed to the key value store. + */ +function coediting_set( $request_data ) { + $body = $request_data->get_body_params(); + $keys = array_keys( $body ); + $key = $keys[0]; + $value = $body[ $key ]; + $kv_key = get_option( $key ); + + list( $type, $peer_id, $user_id, $signal ) = get_peer_data( $value ); + + $attributes = $request_data->get_attributes(); + + if ( ! $user_id ) { + $current_user = wp_get_current_user(); + $user_id = $current_user->ID; + } + + // Assuming true initiator unset if required. + $kv_value = array( + 'peer_id' => $peer_id, + 'type' => $type, + 'user_id' => $user_id, + 'initiator' => true, + ); + + // If no option in db, create new one. + if ( ! $kv_key && 'initial' === $type ) { + add_option( $key, array( $kv_value ) ); + + return $kv_value; + } + + // If key is not empty, new peer is not initiator. + if ( $kv_key && 'initial' === $type ) { + // Duplicate request check. + $check_peer = peer_exist_update( $kv_key, $user_id, $peer_id ); + if ( $check_peer[0] ) { + update_option( $key, $check_peer[2] ); + + return $check_peer[1]; + } + + // Reset the initiator to false if initiator already exist. + $kv_value['initiator'] = false; + $kv_key[] = $kv_value; + update_option( $key, $kv_key ); + + return $kv_value; + } + + if ( 'register' === $type ) { + for ( $i = 0; $i < count( $kv_key ); $i ++ ) { + if ( $kv_key[ $i ]['peer_id'] == $peer_id ) { + $kv_key[ $i ]['signal'] = $signal; + break; + } + } + update_option( $key, $kv_key ); + + return get_option( $key ); + } +} + +/** + * Removes the key from the signal's key value store. + * + * @since 1.3.0 + * + * @param object $request_data Request data. + * + * @return string Data which is pushed to the key value store. + */ +function coediting_remove( $request_data ) { + $body = $request_data->get_body_params(); + $keys = array_keys( $body ); + $key = $keys[0]; + $value = $body[ $key ]; + + list( $type, $peer_id, $user_id, $signal ) = get_peer_data( $value ); + + // Assuming true initiator unset if required. + $kv_value = array( + 'peer_id' => $peer_id, + 'type' => $type, + 'initiator' => true, + 'signal' => $signal, + ); + + // Reset the data of peer. + update_option( $key, array( $kv_value ) ); + + return $kv_value; +} diff --git a/lib/collaboration.php b/lib/collaboration.php deleted file mode 100644 index 71eb034a889d50..00000000000000 --- a/lib/collaboration.php +++ /dev/null @@ -1,204 +0,0 @@ - 'GET', - 'callback' => 'get_username', - 'args' => array( - 'username' => $current_user->user_login - ) - )); - - register_rest_route( 'collaborate', '/get/(?P\w+.*)', array( - 'methods' => 'GET', - 'callback' => 'collaboration_get', - )); - - register_rest_route( 'collaborate', '/set', array( - 'methods' => 'POST', - 'callback' => 'collaboration_set', - )); - - register_rest_route( 'collaborate', '/remove', array( - 'methods' => 'POST', - 'callback' => 'collaboration_remove', - )); -} - -/** - * Returns the username who is logged in. - * - * @param object $data as request data along with args. - * @return bool else username if logged in. - */ -function get_username( $data ){ - $username = $data->get_attributes(); - return $username['args']['username']; -} - -/** - * Check if string is valid base64. - * - * @param string base64 - * @return bool if its valid - */ -function base64_check( $string ) { - $decoded = base64_decode( $string, true ); - - // Check if there is no invalid character in string - if ( ! preg_match( '/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $string ) ) return false; - - // String mode check - if ( ! base64_decode( $string, true ) ) return false; - - // Encode and compare it to original one - if ( base64_encode( $decoded ) != $string ) return false; - - return true; -} - -/** - * Check if array has same peer already - * - * @param array decoded from request - * @return [string, string, string, string] type, peerID, peerName, signal - */ - function get_peer_data( $request_value ) { - if ( base64_check( $request_value ) ) { - $decoded_data = json_decode( base64_decode( $request_value ) ); - } - - $type = isset( $decoded_data->type ) ? $decoded_data->type : ''; - $peerID = isset( $decoded_data->peerID ) ? $decoded_data->peerID : ''; - $signal = isset( $decoded_data->signal ) ? $decoded_data->signal : ''; - $peerName = isset( $decoded_data->peerName ) ? $decoded_data->peerName : ''; - - return [$type, $peerID, $peerName, $signal]; - } - - -/** - * Check if array has same peer already - * - * @param array fetch from db - * @param peerName from request - * - * @return array [boolean, array, array ] - */ -function peer_exist_update( $kv_key, $peerName, $peerID ) { - for ( $i = 0; $i < count( $kv_key ); $i++ ) { - if ( $kv_key[ $i ][ 'peerName' ] == $peerName ) { - $kv_key[ $i ][ 'peerID' ] = $peerID; - $kv_key[ $i ][ 'signal' ] = false; - return [ true, $kv_key[ $i ], $kv_key ]; - } - } - return [ false, [], [] ]; -} - -/** - * Get the data from signal key value store. - * - * @param object request data - * @return string data fetched from key value store. - */ -function collaboration_get( $request_data ) { - return get_option( $request_data['key'] ); -} - -/** - * Set the data from route to signal key value store. - * - * @param object request data - * @return string data which is pushed to key value store. - */ -function collaboration_set( $request_data ) { - $body = $request_data->get_body_params(); - $key = array_keys( $body )[0]; - $value = $body[ $key ]; - $kv_key = get_option( $key ); - - list( $type, $peerID, $peerName, $signal ) = get_peer_data( $value ); - - // Assuming true initiator unset if required. - $kv_value = array( - 'peerID' => $peerID, - 'type' => $type, - 'peerName' => $peerName, - 'initiator' => true - ); - - // If no option in db, create new one. - if ( ! $kv_key && $type === 'initial' ) { - add_option( $key, [ $kv_value ] ); - return $kv_value; - } - - // If key is not empty, new peer is not initiator - if ( $kv_key && $type === 'initial' ) { - // duplicate request check - $check_peer = peer_exist_update( $kv_key, $peerName, $peerID ); - if ( $check_peer[ 0 ] ) { - update_option( $key, $check_peer[2] ); - return $check_peer[1]; - } - - // reset the initiator to false if initiator already exist. - $kv_value['initiator'] = false; - $kv_key[] = $kv_value; - update_option( $key, $kv_key ); - return $kv_value; - } - - if ( $type === 'register' ) { - for ( $i = 0; $i < count( $kv_key ); $i++ ) { - if ( $kv_key[ $i ][ 'peerID' ] == $peerID ) { - $kv_key[ $i ][ 'signal' ] = $signal; - break; - } - } - update_option( $key, $kv_key ); - return get_option( $key ); - } -} - -/** - * Remove the key from signal key value store. - * - * @param object request data - * @return string data which is pushed to key value store. - */ -function collaboration_remove( $request_data ) { - $body = $request_data->get_body_params(); - $key = array_keys( $body )[0]; - $value = $body[ $key ]; - - list( $type, $peerID, $peerName, $signal ) = get_peer_data( $value ); - - // Assuming true initiator unset if required. - $kv_value = array( - 'peerID' => $peerID, - 'type' => $type, - 'initiator' => true, - 'signal' => $signal - ); - - // Reset the data of peer. - update_option( $key, [ $kv_value ]); - return $kv_value; -} - -?> \ No newline at end of file diff --git a/lib/load.php b/lib/load.php index 0b1bad322a6497..0a67410e9c99fd 100644 --- a/lib/load.php +++ b/lib/load.php @@ -20,7 +20,7 @@ require dirname( __FILE__ ) . '/i18n.php'; require dirname( __FILE__ ) . '/parser.php'; require dirname( __FILE__ ) . '/register.php'; -require dirname( __FILE__ ) . '/collaboration.php'; +require dirname( __FILE__ ) . '/coediting.php'; // Register server-side code for individual blocks. foreach ( glob( dirname( __FILE__ ) . '/../blocks/library/*/index.php' ) as $block_logic ) { diff --git a/package-lock.json b/package-lock.json index 0125e8325c7095..cb5c3236b69980 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2295,8 +2295,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "2.2.2", @@ -4933,6 +4932,11 @@ "globule": "1.2.0" } }, + "get-browser-rtc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz", + "integrity": "sha1-u81AyEUaftTvXDc7gWmkCd0dEdk=" + }, "get-caller-file": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", @@ -5343,8 +5347,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.4", @@ -8977,8 +8980,7 @@ "process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" }, "progress": { "version": "2.0.0", @@ -9156,7 +9158,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.5.tgz", "integrity": "sha512-8T7Zn1AhMsQ/HI1SjcCfT/t4ii3eAqco3yOcSzS4mozsOz69lHLsoMXmF9nZgnFanYscnSlUSgs8uZyKzpE6kg==", - "dev": true, "requires": { "safe-buffer": "5.1.1" } @@ -9440,7 +9441,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", "integrity": "sha1-No8lEtefnUb9/HE0mueHi7weuVw=", - "dev": true, "requires": { "core-util-is": "1.0.2", "inherits": "2.0.3", @@ -9893,8 +9893,7 @@ "safe-buffer": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha1-iTMSr2myEj3vcfV4iQAWce6yyFM=", - "dev": true + "integrity": "sha1-iTMSr2myEj3vcfV4iQAWce6yyFM=" }, "sane": { "version": "2.2.0", @@ -10279,6 +10278,18 @@ "resolved": "https://registry.npmjs.org/simple-html-tokenizer/-/simple-html-tokenizer-0.4.1.tgz", "integrity": "sha1-AomIu3/osuZkVnbYIFJYfUQLAtM=" }, + "simple-peer": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-8.1.1.tgz", + "integrity": "sha512-t2zRYgj1HE5lbfkuL2bJ8s8Q60TQfPwOfDj/TA1/N/Qvi8pdj4uEU0bctrZIsHZlzuU7HM+RFR/YBbiLJjHpxQ==", + "requires": { + "debug": "2.6.8", + "get-browser-rtc": "1.0.2", + "inherits": "2.0.3", + "randombytes": "2.0.5", + "readable-stream": "2.3.3" + } + }, "slash": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", @@ -10486,7 +10497,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", "integrity": "sha1-D8Z9fBQYJd6UKC3VNr7GubzoYKs=", - "dev": true, "requires": { "safe-buffer": "5.1.1" } @@ -11013,8 +11023,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { "version": "3.1.0", diff --git a/package.json b/package.json index 6e46c8ebbcb5bf..84f8aaca122c18 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "moment": "2.18.1", "moment-timezone": "0.5.13", "mousetrap": "1.6.1", - "node-forge": "0.7.1", "prop-types": "15.5.10", "re-resizable": "4.0.3", "react": "16.2.0",