jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/353587 )

Change subject: rebaser: Implement author names
......................................................................


rebaser: Implement author names

Allow user to change their display name, and display this name
next to their selection and in a list in a sidebar.

Adds handling of author disconnects and session usurpation:

When clients register, they receive a token which they store in sessionStorage,
then send back to the server when reconnecting. The server validates this token
and allows the client to resume the session.

Also put all author data in one map.

Change-Id: I9e6f1b01ac7cc55d532b7b75494324cdcb99c60c
---
M rebaser/demo.js
M rebaser/server.js
M src/ce/styles/ve.ce.Surface.css
M src/ce/ve.ce.Surface.js
M src/dm/ve.dm.RebaseDocState.js
M src/dm/ve.dm.RebaseServer.js
M src/dm/ve.dm.SurfaceSynchronizer.js
7 files changed, 228 insertions(+), 56 deletions(-)

Approvals:
  Esanders: Looks good to me, but someone else must approve
  jenkins-bot: Verified
  Jforrester: Looks good to me, approved



diff --git a/rebaser/demo.js b/rebaser/demo.js
index 944fa7e..5d47eeb 100644
--- a/rebaser/demo.js
+++ b/rebaser/demo.js
@@ -7,11 +7,77 @@
 new ve.init.sa.Platform( ve.messagePaths ).initialize().done( function () {
        var synchronizer,
                $editor = $( '.ve-demo-editor' ),
+               $sidebar = $( '<div>' ),
+               nameInput = new OO.ui.TextInputWidget(),
+               changeNameButton = new OO.ui.ButtonWidget( { label: 'Change 
name' } ),
+               authorList = new OO.ui.SelectWidget(),
                // eslint-disable-next-line new-cap
                target = new ve.demo.target();
 
-       $editor.append( target.$element );
+       function updateName() {
+               synchronizer.changeName( nameInput.getValue() );
+       }
+
+       $sidebar.append(
+               // FIXME FieldLayouts exist for this purpose
+               nameInput.$element
+                       .css( { display: 'inline-block', width: 'auto' } ),
+               changeNameButton.$element,
+               authorList.$element
+       );
+
+       $editor
+               .append(
+                       $( '<div>' )
+                               .css( { display: 'table', width: '100%' } )
+                               .append(
+                                       $( '<div>' )
+                                               .css( { display: 'table-row' } )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .css( { 
display: 'table-cell', width: '80%' } )
+                                                               .append( 
target.$element ),
+                                                       $sidebar
+                                                               .css( { 
display: 'table-cell', 'padding-left': '1em' } )
+                                               )
+                               )
+               );
        target.addSurface( ve.dm.converter.getModelFromDom( 
ve.createDocumentFromHtml( '' ) ) );
        synchronizer = new ve.dm.SurfaceSynchronizer( target.surface.model, 
ve.docName );
        target.surface.view.setSynchronizer( synchronizer );
+
+       synchronizer.on( 'authorNameChange', function ( authorId ) {
+               var color,
+                       authorLabel = authorList.getItemFromData( String( 
authorId ) );
+               if ( !authorLabel ) {
+                       // FIXME: Duplicated from SurfaceSynchronizer
+                       color = '#' +
+                               ( 8 * ( 1 - Math.sin( 5 * authorId ) ) 
).toString( 16 ).slice( 0, 1 ) +
+                               ( 6 * ( 1 - Math.cos( 3 * authorId ) ) 
).toString( 16 ).slice( 0, 1 ) +
+                               '0';
+
+                       // FIXME use something more suitable than 
DecoratedOptionWidget
+                       authorLabel = new OO.ui.DecoratedOptionWidget( {
+                               data: String( authorId ),
+                               // HACK: force the icon to show, but override 
the background with a color
+                               icon: 'none'
+                       } );
+                       authorLabel.$icon.css( 'background', color );
+                       authorList.addItems( [ authorLabel ] );
+               }
+               authorLabel.setLabel( String( synchronizer.authorNames[ 
authorId ] ) );
+               if ( String( authorId ) === String( synchronizer.author ) ) {
+                       nameInput.setValue( String( synchronizer.authorNames[ 
authorId ] ) );
+               }
+       } );
+
+       synchronizer.on( 'authorDisconnect', function ( authorId ) {
+               var authorLabel = authorList.getItemFromData( String( authorId 
) );
+               if ( authorLabel ) {
+                       authorList.removeItems( [ authorLabel ] );
+               }
+       } );
+
+       changeNameButton.on( 'click', updateName );
+       nameInput.on( 'enter', updateName );
 } );
diff --git a/rebaser/server.js b/rebaser/server.js
index 99452fa..755254a 100644
--- a/rebaser/server.js
+++ b/rebaser/server.js
@@ -36,20 +36,54 @@
 function makeConnectionHandler( docName ) {
        return function handleConnection( socket ) {
                var history = rebaseServer.getDocState( docName ).history,
-                       author = 1 + ( lastAuthorForDoc.get( docName ) || 0 );
+                       author = 1 + ( lastAuthorForDoc.get( docName ) || 0 ),
+                       authorData = rebaseServer.getAuthorData( docName, 
author );
                lastAuthorForDoc.set( docName, author );
+               rebaseServer.setAuthorName( docName, author, 'Anonymous coward 
' + author );
                logServerEvent( {
                        type: 'newClient',
                        doc: docName,
                        author: author
                } );
-               socket.emit( 'registered', author );
+               socket.emit( 'registered', {
+                       authorId: author,
+                       authorName: authorData.displayName,
+                       token: authorData.token
+               } );
+               docNamespaces.get( docName ).emit( 'nameChange', { authorId: 
author, authorName: authorData.displayName } );
+               socket.on( 'usurp', function ( data ) {
+                       var newAuthorData = rebaseServer.getAuthorData( 
docName, data.authorId );
+                       if ( newAuthorData.token !== data.token ) {
+                               socket.emit( 'usurpFailed' );
+                               return;
+                       }
+                       newAuthorData.active = true;
+                       socket.emit( 'registered', {
+                               authorId: data.authorId,
+                               authorName: newAuthorData.displayName,
+                               token: newAuthorData.token
+                       } );
+                       docNamespaces.get( docName ).emit( 'nameChange', { 
authorId: data.authorId, authorName: newAuthorData.displayName } );
+                       docNamespaces.get( docName ).emit( 'authorDisconnect', 
author );
+                       rebaseServer.removeAuthor( docName, author );
+                       author = data.authorId;
+               } );
                // HACK Catch the client up on the current state by sending it 
the entire history
                // Ideally we'd be able to initialize the client using HTML, 
but that's hard, see
                // comments in the /raw handler. Keeping an updated linmod on 
the server could be
                // feasible if TransactionProcessor was modified to have a 
"don't sync, just apply"
                // mode and ve.dm.Document was faked with { data: ..., 
metadata: ..., store: ... }
-               socket.emit( 'initDoc', history.serialize( true ) );
+               socket.emit( 'initDoc', { history: history.serialize( true ), 
names: rebaseServer.getAllNames( docName ) } );
+               socket.on( 'changeName', function ( newName ) {
+                       logServerEvent( {
+                               type: 'nameChange',
+                               doc: docName,
+                               author: author,
+                               newName: newName
+                       } );
+                       rebaseServer.setAuthorName( docName, author, newName );
+                       docNamespaces.get( docName ).emit( 'nameChange', { 
authorId: author, authorName: newName } );
+               } );
                socket.on( 'submitChange', setTimeout.bind( null, function ( 
data ) {
                        var change, applied;
                        try {
@@ -68,8 +102,9 @@
                        logEvent( event );
                } );
                socket.on( 'disconnect', function () {
-                       var change = rebaseServer.applyUnselect( docName, 
author );
-                       docNamespaces.get( docName ).emit( 'newChange', 
change.serialize( true ) );
+                       var authorData = rebaseServer.getAuthorData( docName, 
author );
+                       authorData.active = false;
+                       docNamespaces.get( docName ).emit( 'authorDisconnect', 
author );
                        logServerEvent( {
                                type: 'disconnect',
                                doc: docName,
diff --git a/src/ce/styles/ve.ce.Surface.css b/src/ce/styles/ve.ce.Surface.css
index 5bee91d..a9402dc 100644
--- a/src/ce/styles/ve.ce.Surface.css
+++ b/src/ce/styles/ve.ce.Surface.css
@@ -77,6 +77,10 @@
        top: -1.4em;
        height: 1em;
        line-height: 1;
+       white-space: nowrap;
+       max-width: 10em;
+       overflow: hidden;
+       text-overflow: ellipsis;
 }
 
 .ve-ce-surface-paste {
diff --git a/src/ce/ve.ce.Surface.js b/src/ce/ve.ce.Surface.js
index 5620fae..8eb9632 100644
--- a/src/ce/ve.ce.Surface.js
+++ b/src/ce/ve.ce.Surface.js
@@ -3971,15 +3971,18 @@
                this.synchronizer.disconnect( this );
        }
        this.synchronizer = synchronizer;
-       this.synchronizer.connect( this, { authorSelect: 
'onSynchronizerAuthorSelect' } );
+       this.synchronizer.connect( this, {
+               authorSelect: 'onSynchronizerAuthorUpdate',
+               authorNameChange: 'onSynchronizerAuthorUpdate'
+       } );
 };
 
 /**
- * Called when the synchronizer receives a remote author selection change
+ * Called when the synchronizer receives a remote author selection or name 
change
  *
  * @param {number} author The author ID
  */
-ve.ce.Surface.prototype.onSynchronizerAuthorSelect = function ( author ) {
+ve.ce.Surface.prototype.onSynchronizerAuthorUpdate = function ( author ) {
        try {
                this.paintAuthor( author );
        } catch ( error ) {
@@ -4000,10 +4003,18 @@
                        '0',
                selection = this.synchronizer.authorSelections[ author ];
 
+       if ( author === this.author ) {
+               return;
+       }
+
        if ( !this.userSelectionOverlays[ author ] ) {
                this.userSelectionOverlays[ author ] = {
                        $cursor: $( '<div>' ),
-                       $selection: $( '<div>' )
+                       $selection: $( '<div>' ),
+                       clearDebounced: ve.debounce( function () {
+                               overlays.$cursor.detach();
+                               overlays.$selection.detach();
+                       }, 2000 )
                };
        }
        overlays = this.userSelectionOverlays[ author ];
@@ -4045,13 +4056,14 @@
                } ).append(
                        $( '<span>' )
                        .addClass( 've-ce-surface-highlights-user-cursor-label' 
)
-                       .text( author )
+                       .text( this.synchronizer.authorNames[ author ] )
                        .css( { background: color } )
                )
        );
 
        this.$highlightsUserCursors.append( overlays.$cursor );
        this.$highlightsUserSelections.append( overlays.$selection );
+       overlays.clearDebounced();
 };
 
 /**
@@ -4067,7 +4079,7 @@
                var author,
                        authorSelections = 
surface.synchronizer.authorSelections;
                for ( author in authorSelections ) {
-                       surface.onSynchronizerAuthorSelect( author );
+                       surface.onSynchronizerAuthorUpdate( author );
                }
        } );
 };
diff --git a/src/dm/ve.dm.RebaseDocState.js b/src/dm/ve.dm.RebaseDocState.js
index e8d6c96..23d8e5f 100644
--- a/src/dm/ve.dm.RebaseDocState.js
+++ b/src/dm/ve.dm.RebaseDocState.js
@@ -12,25 +12,17 @@
  * @class
  *
  * @constructor
- * @param {ve.dm.Change} history History as one big change
- * @param {Map.<number,ve.dm.Change>} continueBases Per-author transposed 
history for rebasing
- * @param {Map.<number,number>} rejections Per-author count of unacknowledged 
rejections
  */
-ve.dm.RebaseDocState = function VeDmRebaseDocState( history, continueBases, 
rejections ) {
+ve.dm.RebaseDocState = function VeDmRebaseDocState() {
        /**
         * @property {ve.dm.Change} history History as one big change
         */
-       this.history = history || new ve.dm.Change( 0, [], [], {} );
+       this.history = new ve.dm.Change( 0, [], [], {} );
 
        /**
-        * @property {Map.<number,ve.dm.Change>} continueBases Per-author 
transposed history for rebasing
+        * @property {Map.<number, Object>} authors Information about each 
author
         */
-       this.continueBases = continueBases || new Map();
-
-       /**
-        * @property {Map.<number,number>} Per-author count of unacknowledged 
rejections
-        */
-       this.rejections = rejections || new Map();
+       this.authors = new Map();
 };
 
 /* Inheritance */
diff --git a/src/dm/ve.dm.RebaseServer.js b/src/dm/ve.dm.RebaseServer.js
index 8ffca48..62b319a 100644
--- a/src/dm/ve.dm.RebaseServer.js
+++ b/src/dm/ve.dm.RebaseServer.js
@@ -35,6 +35,21 @@
        return this.stateForDoc.get( doc );
 };
 
+ve.dm.RebaseServer.prototype.getAuthorData = function ( doc, author ) {
+       var state = this.getDocState( doc );
+       if ( !state.authors.has( author ) ) {
+               state.authors.set( author, {
+                       displayName: '',
+                       rejections: 0,
+                       continueBase: null,
+                       // TODO use cryptographic randomness here and convert 
to hex
+                       token: Math.random(),
+                       active: true
+               } );
+       }
+       return state.authors.get( author );
+};
+
 /**
  * Update document history
  *
@@ -45,16 +60,33 @@
  * @param {ve.dm.Change} [continueBase] Continue base for author
  */
 ve.dm.RebaseServer.prototype.updateDocState = function ( doc, author, 
newHistory, rejections, continueBase ) {
-       var state = this.getDocState( doc );
+       var state = this.getDocState( doc ),
+               authorData = state.authors.get( author );
        if ( newHistory ) {
                state.history.push( newHistory );
        }
        if ( rejections !== undefined ) {
-               state.rejections.set( author, rejections );
+               authorData.rejections = rejections;
        }
        if ( continueBase ) {
-               state.continueBases.set( author, continueBase );
+               authorData.continueBase = continueBase;
        }
+};
+
+ve.dm.RebaseServer.prototype.setAuthorName = function ( doc, authorId, 
authorName ) {
+       var authorData = this.getAuthorData( doc, authorId );
+       authorData.displayName = authorName;
+};
+
+ve.dm.RebaseServer.prototype.getAllNames = function ( doc ) {
+       var result = {},
+               state = this.getDocState( doc );
+       state.authors.forEach( function ( authorData, authorId ) {
+               if ( authorData.active ) {
+                       result[ authorId ] = authorData.displayName;
+               }
+       } );
+       return result;
 };
 
 /**
@@ -72,10 +104,11 @@
  */
 ve.dm.RebaseServer.prototype.applyChange = function applyChange( doc, author, 
backtrack, change ) {
        var base, rejections, result, appliedChange,
-               state = this.getDocState( doc );
+               state = this.getDocState( doc ),
+               authorData = this.getAuthorData( doc, author );
 
-       base = state.continueBases.get( author ) || change.truncate( 0 );
-       rejections = state.rejections.get( author ) || 0;
+       base = authorData.continueBase || change.truncate( 0 );
+       rejections = authorData.rejections || 0;
        if ( rejections > backtrack ) {
                // Follow-on does not fully acknowledge outstanding conflicts: 
reject entirely
                rejections = rejections - backtrack + 
change.transactions.length;
@@ -110,17 +143,7 @@
        return appliedChange;
 };
 
-/**
- * Apply a change that nulls out the given author's selection.
- *
- * @param {string} doc Document name
- * @param {string} author Author ID
- * @return {ve.dm.Change} Change that was applied
- */
-ve.dm.RebaseServer.prototype.applyUnselect = function ( doc, author ) {
-       var state = this.getDocState( doc ),
-               change = state.history.mostRecent( state.history.start + 
state.history.getLength() );
-       change.selections[ author ] = new ve.dm.NullSelection( null );
-       this.updateDocState( doc, author, change );
-       return change;
+ve.dm.RebaseServer.prototype.removeAuthor = function ( doc, author ) {
+       var state = this.getDocState( doc );
+       state.authors.delete( author );
 };
diff --git a/src/dm/ve.dm.SurfaceSynchronizer.js 
b/src/dm/ve.dm.SurfaceSynchronizer.js
index 33960e2..2995b25 100644
--- a/src/dm/ve.dm.SurfaceSynchronizer.js
+++ b/src/dm/ve.dm.SurfaceSynchronizer.js
@@ -30,6 +30,7 @@
        this.doc = surface.documentModel;
        this.store = this.doc.getStore();
        this.authorSelections = {};
+       this.authorNames = {};
        this.documentId = documentId;
 
        // Whether the document has been initialized
@@ -42,6 +43,9 @@
        this.socket.on( 'registered', this.onRegistered.bind( this ) );
        this.socket.on( 'initDoc', this.onInitDoc.bind( this ) );
        this.socket.on( 'newChange', this.onNewChange.bind( this ) );
+       this.socket.on( 'nameChange', this.onNameChange.bind( this ) );
+       this.socket.on( 'authorDisconnect', this.onAuthorDisconnect.bind( this 
) );
+       this.tryUsurp();
 
        // Events
        this.doc.connect( this, {
@@ -199,37 +203,73 @@
                } else {
                        translatedSelection = newSelections[ author ];
                }
-               // Test equality before assigning, in case 
this.authorSelections === newSelections
-               // changed = !translatedSelection.equals( 
this.authorSelections[ author ] );
-               this.authorSelections[ author ] = translatedSelection;
-               this.emit( 'authorSelect', author );
+               if ( !translatedSelection.equals( this.authorSelections[ author 
] ) ) {
+                       this.authorSelections[ author ] = translatedSelection;
+                       this.emit( 'authorSelect', author );
+               }
        }
+};
+
+ve.dm.SurfaceSynchronizer.prototype.onNameChange = function ( data ) {
+       this.authorNames[ data.authorId ] = data.authorName;
+       this.emit( 'authorNameChange', data.authorId );
+};
+
+ve.dm.SurfaceSynchronizer.prototype.changeName = function ( newName ) {
+       this.socket.emit( 'changeName', newName );
+};
+
+ve.dm.SurfaceSynchronizer.prototype.onAuthorDisconnect = function ( authorId ) 
{
+       delete this.authorSelections[ authorId ];
+       this.emit( 'authorDisconnect', authorId );
 };
 
 /**
  * Respond to a "registered" event from the server
  *
- * @param {number} author The author ID allocated by the server
+ * @param {Object} data
+ * @param {number} data.authorId The author ID allocated by the server
  */
-ve.dm.SurfaceSynchronizer.prototype.onRegistered = function ( author ) {
-       this.setAuthor( author );
+ve.dm.SurfaceSynchronizer.prototype.onRegistered = function ( data ) {
+       this.setAuthor( data.authorId );
        this.surface.setAuthor( this.author );
-       // HACK
-       $( '.ve-demo-editor' ).prepend( $( '<span style="position: absolute; 
top: 1.5em;">' ).text( this.author ) );
+
+       if ( window.sessionStorage ) {
+               window.sessionStorage.setItem( 'visualeditor-author', 
JSON.stringify( data ) );
+       }
+};
+
+ve.dm.SurfaceSynchronizer.prototype.tryUsurp = function () {
+       var storedData = window.sessionStorage && 
window.sessionStorage.getItem( 'visualeditor-author' );
+       if ( !storedData ) {
+               return;
+       }
+       try {
+               storedData = JSON.parse( storedData );
+               this.socket.emit( 'usurp', storedData );
+       } catch ( e ) {
+               // eslint-disable-next-line no-console
+               console.warn( 'Failed to parse data in session storage', e );
+       }
 };
 
 /**
  * Respond to an initDoc event from the server, catching us up on the prior 
history of the document.
  *
- * @param {Object} serializedHistory Serialized change representing the 
server's history
+ * @param {Object} data
+ * @param {Object} data.history Serialized change representing the server's 
history
+ * @param {Object} data.names Object mapping author IDs to names
  */
-ve.dm.SurfaceSynchronizer.prototype.onInitDoc = function ( serializedHistory ) 
{
-       var history;
+ve.dm.SurfaceSynchronizer.prototype.onInitDoc = function ( data ) {
+       var history, authorId;
        if ( this.initialized ) {
                // Ignore attempt to initialize a second time
                return;
        }
-       history = ve.dm.Change.static.deserialize( serializedHistory, this.doc 
);
+       for ( authorId in data.names ) {
+               this.onNameChange( { authorId: authorId, authorName: 
data.names[ authorId ] } );
+       }
+       history = ve.dm.Change.static.deserialize( data.history, this.doc );
        this.acceptChange( history );
        this.initialized = true;
 };

-- 
To view, visit https://gerrit.wikimedia.org/r/353587
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I9e6f1b01ac7cc55d532b7b75494324cdcb99c60c
Gerrit-PatchSet: 5
Gerrit-Project: VisualEditor/VisualEditor
Gerrit-Branch: master
Gerrit-Owner: Catrope <[email protected]>
Gerrit-Reviewer: Divec <[email protected]>
Gerrit-Reviewer: Esanders <[email protected]>
Gerrit-Reviewer: Jforrester <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to