const SocketController = require('flitter-socket/Controller') const uuid = require('uuid').v4 class NormEditorGroup { constructor(page, node) { this.id = `${page.UUID}-${node.UUID}` this.page = page this.node = node this.connections = {} this.connection_id_x_session = {} } join(connection_manager) { this.connections[connection_manager.id] = connection_manager this.connection_id_x_session[connection_manager.id] = {} connection_manager.on_close().then(() => { delete this.connection_id_x_session[connection_manager.id] delete this.connections[connection_manager.id] this.blast_editor_group_users() }) } has(connection_manager) { return !!this.connections[connection_manager.id] } unique_users() { const users = {} const conns = Object.values(this.connections) for ( const conn of conns ) { const session = this.session(conn) const user = conn.request.user if ( user && !users[user.uuid] ) { if ( !session.user_color ) { session.user_color = '#' + Math.floor(Math.random() * 16777215).toString(16) } users[user.uuid] = { user, color: session.user_color } } } return Object.values(users) } session(conn) { return this.connection_id_x_session[conn.id] } blast_editor_group_users() { const conns = Object.values(this.connections) const unique_users = this.unique_users() for ( const conn of conns ) { const users = unique_users.filter(data => data.user.uuid !== conn.request.user.uuid) .map(data => { const { user, color } = data return { uuid: user.uuid, uid: user.uid, display: user.uid, color, } }) conn._request('setEditorGroupUsers', { users }) } } blast_selections() { const conns = Object.values(this.connections) for ( const conn of conns ) { const selections = conns.filter(maybeConn => conn.id !== maybeConn.id) .map(otherConn => { const session = this.session(otherConn) const user_data = { uuid: otherConn.request.user.uuid, uid: otherConn.request.user.uid, display: otherConn.request.user.uid, } if ( session.user_color ) { user_data.color = session.user_color } return {...(this.session(otherConn).selection || {}), user_data} }) .filter(Boolean) conn._request('setEditorGroupSelections', { selections }) } } blast_content_mutation(mutation, exclude_conn_id) { const conns = Object.values(this.connections) for ( const conn of conns ) { if ( conn.id === exclude_conn_id ) continue; conn._request('applyRemoteContentMutation', { mutation }) } } set_member_selection(conn, selection) { const session = this.session(conn) if ( session ) { session.selection = selection this.blast_selections() } } } class NormEditorController extends SocketController { editor_groups = {} static get services() { return [...super.services, 'output', 'models'] } ping(transaction, socket) { this.output.info('Got norm editor socket ping.') transaction.status(200) .message('Pong!') .send(transaction.incoming) } _get_editor_group_or_fail(transaction) { if ( !transaction.incoming.editor_group_id ) { transaction.status(400) .message('Missing editor_group_id.') .send() return } const editor_group = this.editor_groups[transaction.incoming.editor_group_id] if ( !editor_group || !editor_group.has(transaction.cm) ) { transaction.status(400) .message('Invalid editor_group_id.') .send() return } return editor_group } set_member_selection(transaction, socket) { const editor_group = this._get_editor_group_or_fail(transaction) if ( !editor_group ) return; if ( !transaction.incoming.selection ) { return transaction.status(400) .message('Missing selection.') .send() } editor_group.set_member_selection(transaction.cm, transaction.incoming.selection) transaction.status(200).send() } get_selections(transaction, socket) { const editor_group = this._get_editor_group_or_fail(transaction) if ( !editor_group ) return; const selections = Object.values(editor_group.connections) .filter(maybeConn => transaction.cm.id !== maybeConn.id) .map(otherConn => { const session = editor_group.session(otherConn) const user_data = { uuid: otherConn.request.user.uuid, uid: otherConn.request.user.uid, display: otherConn.request.user.uid, } if ( session.user_color ) { user_data.color = session.user_color } return {...(editor_group.session(otherConn).selection || {}), user_data} }) .filter(Boolean) transaction.status(200) .send({ selections }) } get_editor_group_users(transaction, socket) { const editor_group = this._get_editor_group_or_fail(transaction) if ( !editor_group ) return; transaction.status(200) .send( editor_group.unique_users() .filter(data => data.user.uuid !== transaction.cm.request.user.uuid) .map(data => { const { user, color } = data return { uuid: user.uuid, uid: user.uid, display: user.uid, color, } }) ) } broadcast_content_mutation(transaction, socket) { const editor_group = this._get_editor_group_or_fail(transaction) if ( !editor_group ) return; if ( transaction.incoming.data.path.toLowerCase().startsWith('body@') ) { return transaction.status(200).send() } console.log('mutation', transaction.incoming.data) editor_group.blast_content_mutation(transaction.incoming.data, transaction.cm.id) transaction.status(200).send() } async join_editor_group(transaction, socket) { // FIXME support versioning const Page = this.models.get('api:Page') const Node = this.models.get('api:Node') if ( !transaction.incoming.PageId ) { return transaction.status(400) .message('Missing pageId.') .send() } const page = await Page.findOne({ UUID: transaction.incoming.PageId, Active: true }) if ( !page || !(await page.is_accessible_by(transaction.cm.request.user, 'update')) ) { return transaction.status(401) .message('Invalid PageId.') .send() } if ( !transaction.incoming.NodeId ) { transaction.status(400) .message('Missing NodeId.') .send() } const node = await Node.findOne({ UUID: transaction.incoming.NodeId, PageId: page.UUID }) if ( !node ) { transaction.status(400) .message('Invalid NodeId.') .send() } // Try to look up an existing editor group const editor_group_id = `${page.UUID}-${node.UUID}` let editor_group = this.editor_groups[editor_group_id] if ( !editor_group ) { // create a new editor group editor_group = new NormEditorGroup(page, node) this.editor_groups[editor_group_id] = editor_group } if ( !editor_group.has(transaction.cm) ) editor_group.join(transaction.cm) transaction.status(200) .message('Joined editor group.') .send({ editor_group_id }) editor_group.blast_editor_group_users() } _client_connected(connection_manager) { super._client_connected(connection_manager) this.output.debug('New client connected to norm editor socket.') if ( !connection_manager.request?.session?.auth?.user_id ) { delete this.connections[connection_manager.id] connection_manager.socket.close() } } } module.exports = exports = NormEditorController