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