diff --git a/Gruntfile.js b/Gruntfile.js index 09b057837..b599d0b0f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -143,6 +143,7 @@ module.exports = function(grunt) { "packages/node_modules/@node-red/editor-client/src/js/user.js", "packages/node_modules/@node-red/editor-client/src/js/comms.js", "packages/node_modules/@node-red/editor-client/src/js/runtime.js", + "packages/node_modules/@node-red/editor-client/src/js/multiplayer.js", "packages/node_modules/@node-red/editor-client/src/js/text/bidi.js", "packages/node_modules/@node-red/editor-client/src/js/text/format.js", "packages/node_modules/@node-red/editor-client/src/js/ui/state.js", diff --git a/packages/node_modules/@node-red/editor-api/lib/editor/theme.js b/packages/node_modules/@node-red/editor-api/lib/editor/theme.js index c3808a751..64e4f7ec3 100644 --- a/packages/node_modules/@node-red/editor-api/lib/editor/theme.js +++ b/packages/node_modules/@node-red/editor-api/lib/editor/theme.js @@ -233,6 +233,10 @@ module.exports = { themeSettings.projects = theme.projects; } + if (theme.hasOwnProperty("multiplayer")) { + themeSettings.multiplayer = theme.multiplayer; + } + if (theme.hasOwnProperty("keymap")) { themeSettings.keymap = theme.keymap; } diff --git a/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js new file mode 100644 index 000000000..926a118a9 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/multiplayer.js @@ -0,0 +1,217 @@ +RED.multiplayer = (function () { + + // sessionId - used to identify sessions across websocket reconnects + let sessionId + + let headerWidget + // Map of session id to { session:'', user:{}, location:{}} + let connections = {} + // Map of username to { user:{}, connections:[] } + let users = {} + + function addUserConnection (connection) { + if (connections[connection.session]) { + // This is an existing connection that has been authenticated + const existingConnection = connections[connection.session] + if (existingConnection.user.username !== connection.user.username) { + removeUserButton(users[existingConnection.user.username]) + } + } + connections[connection.session] = connection + const user = users[connection.user.username] = users[connection.user.username] || { + user: connection.user, + connections: [] + } + connection.location = connection.location || {} + user.connections.push(connection) + + if (connection.user.username === RED.settings.user?.username || + connection.session === sessionId + ) { + // This is the current user - do not add a extra button for them + } else { + if (user.connections.length === 1) { + if (user.button) { + clearTimeout(user.inactiveTimeout) + clearTimeout(user.removeTimeout) + user.button.removeClass('inactive') + } else { + addUserButton(user) + } + } + } + } + + function removeUserConnection (session, isDisconnected) { + const connection = connections[session] + delete connections[session] + const user = users[connection.user.username] + const i = user.connections.indexOf(connection) + user.connections.splice(i, 1) + if (isDisconnected) { + removeUserButton(user) + } else { + if (user.connections.length === 0) { + // Give the user 5s to reconnect before marking inactive + user.inactiveTimeout = setTimeout(() => { + user.button.addClass('inactive') + // Give the user further 20 seconds to reconnect before removing them + // from the user toolbar entirely + user.removeTimeout = setTimeout(() => { + removeUserButton(user) + }, 20000) + }, 5000) + } + } + } + + function addUserButton (user) { + user.button = $('
  • ') + .attr('data-username', user.user.username) + .prependTo("#red-ui-multiplayer-user-list"); + var button = user.button.find("button") + button.on('click', function () { + RED.popover.create({ + target:button, + trigger: 'modal', + interactive: true, + width: "250px", + direction: 'bottom', + content: () => { + const content = $('
    ') + $('
    ').text(user.user.username).appendTo(content) + + const location = user.connections[0].location + if (location.workspace) { + const ws = RED.nodes.workspace(location.workspace) || RED.nodes.subflow(location.workspace) + if (ws) { + $('
    ').text(`${ws.type}: ${ws.label||ws.name||ws.id}`).appendTo(content) + } else { + $('
    ').text(`tab: unknown`).appendTo(content) + } + } + if (location.node) { + const node = RED.nodes.node(location.node) + if (node) { + $('
    ').text(`node: ${node.id}`).appendTo(content) + } else { + $('
    ').text(`node: unknown`).appendTo(content) + } + } + return content + }, + }).open() + }) + if (!user.user.image) { + $('').appendTo(button); + } else { + $('').css({ + backgroundImage: "url("+user.user.image+")", + }).appendTo(button); + } + } + + function getLocation () { + const location = { + workspace: RED.workspaces.active() + } + const editStack = RED.editor.getEditStack() + for (let i = editStack.length - 1; i >= 0; i--) { + if (editStack[i].id) { + location.node = editStack[i].id + break + } + } + return location + } + function updateLocation () { + const location = getLocation() + if (location.workspace !== 0) { + log('send', 'multiplayer/location', location) + RED.comms.send('multiplayer/location', location) + } + } + + function removeUserButton (user) { + user.button.remove() + delete user.button + } + + function updateUserLocation (data) { + connections[data.session].location = data + delete data.session + } + return { + init: function () { + + + sessionId = RED.settings.getLocal('multiplayer:sessionId') + if (!sessionId) { + sessionId = RED.nodes.id() + RED.settings.setLocal('multiplayer:sessionId', sessionId) + } + + headerWidget = $('
    • ').prependTo('.red-ui-header-toolbar') + + RED.comms.on('connect', () => { + const location = getLocation() + const connectInfo = { + session: sessionId + } + if (location.workspace !== 0) { + connectInfo.location = location + } + RED.comms.send('multiplayer/connect', connectInfo) + }) + RED.comms.subscribe('multiplayer/#', (topic, msg) => { + log('recv', topic, msg) + if (topic === 'multiplayer/init') { + // We have just reconnected, runtime has sent state to + // initialise the world + connections = {} + users = {} + $('#red-ui-multiplayer-user-list').empty() + + msg.forEach(connection => { + addUserConnection(connection) + }) + } else if (topic === 'multiplayer/connection-added') { + addUserConnection(msg) + } else if (topic === 'multiplayer/connection-removed') { + removeUserConnection(msg.session, msg.disconnected) + } else if (topic === 'multiplayer/location') { + updateUserLocation(msg) + } + }) + + RED.events.on('workspace:change', (event) => { + updateLocation() + }) + RED.events.on('editor:open', () => { + updateLocation() + }) + RED.events.on('editor:close', () => { + updateLocation() + }) + RED.events.on('editor:change', () => { + updateLocation() + }) + RED.events.on('login', () => { + updateLocation() + }) + RED.events.on('logout', () => { + const disconnectInfo = { + session: sessionId + } + RED.comms.send('multiplayer/disconnect', disconnectInfo) + RED.settings.removeLocal('multiplayer:sessionId') + }) + } + } + + function log() { + if (RED.multiplayer.DEBUG) { + console.log('[multiplayer]', ...arguments) + } + } +})(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/red.js b/packages/node_modules/@node-red/editor-client/src/js/red.js index d13d7ca24..5bdd5af7d 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/red.js +++ b/packages/node_modules/@node-red/editor-client/src/js/red.js @@ -803,6 +803,10 @@ var RED = (function() { RED.nodes.init(); RED.runtime.init() + + if (RED.settings.theme("multiplayer.enabled",false)) { + RED.multiplayer.init() + } RED.comms.connect(); $("#red-ui-main-container").show(); diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js index 9ddd3d866..1a70839ae 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js @@ -211,7 +211,7 @@ RED.popover = (function() { closePopup(true); }); } - if (trigger === 'hover' && options.interactive) { + if (/*trigger === 'hover' && */options.interactive) { div.on('mouseenter', function(e) { clearTimeout(timer); active = true; diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js b/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js index 0ea6d6044..d428d8a00 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js @@ -264,6 +264,7 @@ setTimeout(function() { oldTray.tray.detach(); showTray(options); + RED.events.emit('editor:change') },250) } else { if (stack.length > 0) { @@ -333,6 +334,7 @@ RED.view.focus(); } else { stack[stack.length-1].tray.css("z-index", "auto"); + RED.events.emit('editor:change') } },250) } diff --git a/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss b/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss new file mode 100644 index 000000000..58fb9472f --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss @@ -0,0 +1,48 @@ +#red-ui-multiplayer-user-list { + display: inline-flex; + align-items: center; + margin: 0 5px; + li { + display: inline-flex; + align-items: center; + width: 30px; + margin: 0 2px; + } + +} + +.red-ui-multiplayer-user-icon { + background: var(--red-ui-header-background); + border: 2px solid var(--red-ui-header-menu-color); + border-radius: 30px; + display: inline-flex; + justify-content: center; + align-items: center; + width: 28px; + height: 28px; + text-align: center; + overflow: hidden; + box-sizing: border-box; + text-decoration: none; + color: var(--red-ui-header-menu-color); + padding: 0px; + margin: 0px; + vertical-align: middle; + + &:focus { + outline: none; + } + + .red-ui-multiplayer-user.inactive & { + opacity: 0.5; + } + .user-profile { + background-position: center center; + background-repeat: no-repeat; + background-size: contain; + display: inline-block; + vertical-align: middle; + width: 28px; + height: 28px; + } +} \ No newline at end of file diff --git a/packages/node_modules/@node-red/editor-client/src/sass/style.scss b/packages/node_modules/@node-red/editor-client/src/sass/style.scss index 412290f78..77148abde 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/style.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/style.scss @@ -73,3 +73,5 @@ @import "radialMenu"; @import "tourGuide"; + +@import "multiplayer"; diff --git a/packages/node_modules/@node-red/runtime/lib/index.js b/packages/node_modules/@node-red/runtime/lib/index.js index 7fc9af041..4ac7cfb5b 100644 --- a/packages/node_modules/@node-red/runtime/lib/index.js +++ b/packages/node_modules/@node-red/runtime/lib/index.js @@ -22,6 +22,7 @@ var storage = require("./storage"); var library = require("./library"); var plugins = require("./plugins"); var settings = require("./settings"); +const multiplayer = require("./multiplayer"); var express = require("express"); var path = require('path'); @@ -135,6 +136,7 @@ function start() { .then(function() { return storage.init(runtime)}) .then(function() { return settings.load(storage)}) .then(function() { return library.init(runtime)}) + .then(function() { return multiplayer.init(runtime)}) .then(function() { if (settings.available()) { if (settings.get('instanceId') === undefined) { diff --git a/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js new file mode 100644 index 000000000..a4108e51f --- /dev/null +++ b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js @@ -0,0 +1,119 @@ +let runtime + +/** + * Active sessions, mapped by multiplayer session ids + */ +const sessions = new Map() + +/** + * Active connections, mapping comms session to multiplayer session + */ +const connections = new Map() + + +function getSessionsList() { + return Array.from(sessions.values()).filter(session => session.active) +} + +module.exports = { + init: function(_runtime) { + runtime = _runtime + runtime.events.on('comms:connection-removed', (opts) => { + const existingSessionId = connections.get(opts.session) + if (existingSessionId) { + connections.delete(opts.session) + const session = sessions.get(existingSessionId) + session.active = false + session.idleTimeout = setTimeout(() => { + sessions.delete(existingSessionId) + }, 30000) + runtime.events.emit('comms', { + topic: "multiplayer/connection-removed", + data: { session: existingSessionId } + }) + } + }) + runtime.events.on('comms:message:multiplayer/connect', (opts) => { + let session + if (!sessions.has(opts.data.session)) { + // Brand new session + let user = opts.user + if (!user || user.anonymous) { + user = user || { anonymous: true } + user.username = `Anon ${Math.floor(Math.random()*100)}` + } + session = { + session: opts.data.session, + user, + active: true + } + sessions.set(opts.data.session, session) + connections.set(opts.session, opts.data.session) + runtime.log.trace(`multiplayer new session:${opts.data.session} user:${user.username}`) + } else { + // Reconnected connection - keep existing state + connections.set(opts.session, opts.data.session) + // const existingConnection = connections.get(opts.data.session) + session = sessions.get(opts.data.session) + session.active = true + runtime.log.trace(`multiplayer reconnected session:${opts.data.session} user:${session.user.username}`) + clearTimeout(session.idleTimeout) + } + // Tell existing sessions about the new connection + runtime.events.emit('comms', { + topic: "multiplayer/connection-added", + excludeSession: opts.session, + data: session + }) + + // Send init info to new connection + const initPacket = { + topic: "multiplayer/init", + data: getSessionsList(), + session: opts.session + } + // console.log('<<', initPacket) + runtime.events.emit('comms', initPacket) + }) + runtime.events.on('comms:message:multiplayer/disconnect', (opts) => { + const existingSessionId = connections.get(opts.session) + connections.delete(opts.session) + sessions.delete(existingSessionId) + + runtime.events.emit('comms', { + topic: "multiplayer/connection-removed", + data: { session: existingSessionId, disconnected: true } + }) + }) + runtime.events.on('comms:message:multiplayer/location', (opts) => { + // console.log('>>>', opts.user, opts.data) + + const sessionId = connections.get(opts.session) + const session = sessions.get(sessionId) + + if (opts.user) { + if (session.user.anonymous !== opts.user.anonymous) { + session.user = opts.user + runtime.events.emit('comms', { + topic: 'multiplayer/connection-added', + excludeSession: opts.session, + data: session + }) + } + } + + session.location = opts.data + + const payload = { + session: sessionId, + workspace: opts.data.workspace, + node: opts.data.node + } + runtime.events.emit('comms', { + topic: 'multiplayer/location', + data: payload, + excludeSession: opts.session + }) + }) + } +} \ No newline at end of file diff --git a/packages/node_modules/node-red/settings.js b/packages/node_modules/node-red/settings.js index 864707538..cb7b58795 100644 --- a/packages/node_modules/node-red/settings.js +++ b/packages/node_modules/node-red/settings.js @@ -437,6 +437,10 @@ module.exports = { } }, + multiplayer: { + /** To enable the Multiplayer feature, set this value to true */ + enabled: false + }, }, /*******************************************************************************