diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index d15cdb6e98..8183c75a20 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend" import { getAutomationStore } from "./store/automation" import { getTemporalStore } from "./store/temporal" import { getThemeStore } from "./store/theme" +import { getUsersStore } from "./store/users" import { derived } from "svelte/store" import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" @@ -12,6 +13,7 @@ export const store = getFrontendStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() export const temporalStore = getTemporalStore() +export const usersStore = getUsersStore() // Setup history for screens export const screenHistoryStore = createHistoryStore({ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 33c82778e3..0a27f576a3 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -37,6 +37,7 @@ import { } from "builderStore/dataBinding" import { makePropSafe as safe } from "@budibase/string-templates" import { getComponentFieldOptions } from "helpers/formFields" +import { createBuilderWebsocket } from "builderStore/websocket" const INITIAL_FRONTEND_STATE = { initialised: false, @@ -84,10 +85,14 @@ const INITIAL_FRONTEND_STATE = { // Onboarding onboarding: false, tourNodes: null, + + // Multi user collab + users: [], } export const getFrontendStore = () => { const store = writable({ ...INITIAL_FRONTEND_STATE }) + let websocket // This is a fake implementation of a "patch" API endpoint to try and prevent // 409s. All screen doc mutations (aside from creation) use this function, @@ -112,9 +117,11 @@ export const getFrontendStore = () => { store.actions = { reset: () => { store.set({ ...INITIAL_FRONTEND_STATE }) + websocket?.disconnect() }, initialise: async pkg => { const { layouts, screens, application, clientLibPath, hasLock } = pkg + websocket = createBuilderWebsocket() await store.actions.components.refreshDefinitions(application.appId) diff --git a/packages/builder/src/builderStore/store/users.js b/packages/builder/src/builderStore/store/users.js new file mode 100644 index 0000000000..dfb592c1c5 --- /dev/null +++ b/packages/builder/src/builderStore/store/users.js @@ -0,0 +1,10 @@ +import { writable } from "svelte/store" + +export const getUsersStore = () => { + const initialValue = { + users: [], + } + const store = writable(initialValue) + + return store +} diff --git a/packages/builder/src/builderStore/websocket.js b/packages/builder/src/builderStore/websocket.js new file mode 100644 index 0000000000..d73ab08f9a --- /dev/null +++ b/packages/builder/src/builderStore/websocket.js @@ -0,0 +1,26 @@ +import { createWebsocket } from "@budibase/frontend-core" +import { store } from "builderStore" + +export const createBuilderWebsocket = () => { + const socket = createWebsocket("/socket/builder") + + socket.on("connect", () => { + socket.emit("get-users", null, response => { + console.log("conntected!", response.users) + store.update(state => ({ + ...state, + users: response.users, + })) + }) + }) + + socket.on("user-update", user => {}) + + socket.on("user-disconnect", user => {}) + + socket.on("connect_error", err => { + console.log("Failed to connect to builder websocket:", err.message) + }) + + return socket +} diff --git a/packages/builder/src/pages/builder/app/[application]/_components/UserAvatars.svelte b/packages/builder/src/pages/builder/app/[application]/_components/UserAvatars.svelte new file mode 100644 index 0000000000..a99c4bebee --- /dev/null +++ b/packages/builder/src/pages/builder/app/[application]/_components/UserAvatars.svelte @@ -0,0 +1,33 @@ + + +
+ {#each users as user} +
+ {user.email[0]} +
+ {/each} +
+ + diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index 2fb1b29c18..e145466b11 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -22,6 +22,7 @@ import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourPopover from "components/portal/onboarding/TourPopover.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" + import UserAvatars from "./_components/UserAvatars.svelte" import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js" export let application @@ -125,10 +126,7 @@ }) onDestroy(() => { - store.update(state => { - state.appId = null - return state - }) + store.actions.reset() }) @@ -211,6 +209,7 @@ {/if}
+
{/if} diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 3b0e74d882..4d1ca1434f 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -24,7 +24,7 @@ import RowHeightButton from "../controls/RowHeightButton.svelte" import ColumnWidthButton from "../controls/ColumnWidthButton.svelte" import NewRow from "./NewRow.svelte" - import { createWebsocket } from "../lib/websocket" + import { createGridWebsocket } from "../lib/websocket" import { MaxCellRenderHeight, MaxCellRenderWidthOverflow, @@ -98,7 +98,7 @@ export const getContext = () => context // Initialise websocket for multi-user - onMount(() => createWebsocket(context)) + onMount(() => createGridWebsocket(context))
diff --git a/packages/frontend-core/src/components/grid/lib/websocket.js b/packages/frontend-core/src/components/grid/lib/websocket.js index ae67cd714d..7ac34b5a95 100644 --- a/packages/frontend-core/src/components/grid/lib/websocket.js +++ b/packages/frontend-core/src/components/grid/lib/websocket.js @@ -1,24 +1,9 @@ import { get } from "svelte/store" -import { io } from "socket.io-client" +import { createWebsocket } from "../../../utils" -export const createWebsocket = context => { +export const createGridWebsocket = context => { const { rows, tableId, users, userId, focusedCellId } = context - - // Determine connection info - const tls = location.protocol === "https:" - const proto = tls ? "wss:" : "ws:" - const host = location.hostname - const port = location.port || (tls ? 443 : 80) - const socket = io(`${proto}//${host}:${port}`, { - path: "/socket/grid", - // Cap reconnection attempts to 3 (total of 15 seconds before giving up) - reconnectionAttempts: 3, - // Delay reconnection attempt by 5 seconds - reconnectionDelay: 5000, - reconnectionDelayMax: 5000, - // Timeout after 4 seconds so we never stack requests - timeout: 4000, - }) + const socket = createWebsocket("/socket/grid") const connectToTable = tableId => { if (!socket.connected) { diff --git a/packages/frontend-core/src/components/grid/stores/users.js b/packages/frontend-core/src/components/grid/stores/users.js index 4cf4bdac52..5190de70da 100644 --- a/packages/frontend-core/src/components/grid/stores/users.js +++ b/packages/frontend-core/src/components/grid/stores/users.js @@ -21,35 +21,6 @@ export const createStores = () => { return 0 } }) - // Enrich users with colors - .map((user, idx) => { - // Generate random colour hue - let hue = 1 - for (let i = 0; i < user.email.length && i < 5; i++) { - hue *= user.email.charCodeAt(i + 1) - hue /= 17 - } - hue = hue % 360 - const color = - idx === 0 - ? "var(--spectrum-global-color-blue-400)" - : `hsl(${hue}, 50%, 40%)` - - // Generate friendly label - let label = user.email - if (user.firstName) { - label = user.firstName - if (user.lastName) { - label += ` ${user.lastName}` - } - } - - return { - ...user, - color, - label, - } - }) ) }, [] diff --git a/packages/frontend-core/src/utils/index.js b/packages/frontend-core/src/utils/index.js index 6ac7c65c62..16dc4f323a 100644 --- a/packages/frontend-core/src/utils/index.js +++ b/packages/frontend-core/src/utils/index.js @@ -3,3 +3,4 @@ export * as JSONUtils from "./json" export * as CookieUtils from "./cookies" export * as RoleUtils from "./roles" export * as Utils from "./utils" +export { createWebsocket } from "./websocket" diff --git a/packages/frontend-core/src/utils/websocket.js b/packages/frontend-core/src/utils/websocket.js new file mode 100644 index 0000000000..85c09acc14 --- /dev/null +++ b/packages/frontend-core/src/utils/websocket.js @@ -0,0 +1,23 @@ +import { io } from "socket.io-client" + +export const createWebsocket = path => { + if (!path) { + throw "A websocket path must be provided" + } + + // Determine connection info + const tls = location.protocol === "https:" + const proto = tls ? "wss:" : "ws:" + const host = location.hostname + const port = location.port || (tls ? 443 : 80) + return io(`${proto}//${host}:${port}`, { + path, + // Cap reconnection attempts to 3 (total of 15 seconds before giving up) + reconnectionAttempts: 3, + // Delay reconnection attempt by 5 seconds + reconnectionDelay: 5000, + reconnectionDelayMax: 5000, + // Timeout after 4 seconds so we never stack requests + timeout: 4000, + }) +} diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts new file mode 100644 index 0000000000..5c23682a7b --- /dev/null +++ b/packages/server/src/websockets/builder.ts @@ -0,0 +1,43 @@ +import authorized from "../middleware/authorized" +import Socket from "./websocket" +import { permissions } from "@budibase/backend-core" +import http from "http" +import Koa from "koa" +import { Table } from "@budibase/types" + +export default class BuilderSocket extends Socket { + constructor(app: Koa, server: http.Server) { + super(app, server, "/socket/builder", [authorized(permissions.BUILDER)]) + + this.io.on("connection", socket => { + // Join a room for this app + const user = socket.data.user + const appId = socket.data.appId + socket.join(appId) + socket.to(appId).emit("user-update", socket.data.user) + console.log(`Builder user connected: ${user?.id}`) + + // Initial identification of connected spreadsheet + socket.on("get-users", async (payload, callback) => { + const sockets = await this.io.in(appId).fetchSockets() + callback({ + users: sockets.map(socket => socket.data.user), + id: user.id, + }) + }) + + // Disconnection cleanup + socket.on("disconnect", () => { + socket.to(appId).emit("user-disconnect", socket.data.user) + }) + }) + } + + emitTableUpdate(ctx: any, table: Table) { + this.io.in(ctx.appId).emit("table-update", { id: table._id, table }) + } + + emitTableDeletion(ctx: any, id: string) { + this.io.in(ctx.appId).emit("table-update", { id, table: null }) + } +} diff --git a/packages/server/src/websockets/index.ts b/packages/server/src/websockets/index.ts index 60cdfb8aed..2761c4d9da 100644 --- a/packages/server/src/websockets/index.ts +++ b/packages/server/src/websockets/index.ts @@ -1,14 +1,17 @@ import http from "http" import Koa from "koa" -import GridSocket from "./grid" import ClientAppSocket from "./client" +import GridSocket from "./grid" +import BuilderSocket from "./builder" let clientAppSocket: ClientAppSocket let gridSocket: GridSocket +let builderSocket: BuilderSocket export const initialise = (app: Koa, server: http.Server) => { clientAppSocket = new ClientAppSocket(app, server) gridSocket = new GridSocket(app, server) + builderSocket = new BuilderSocket(app, server) } export { clientAppSocket, gridSocket } diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index 1b34168f14..0e0e6d59a4 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -59,13 +59,36 @@ export default class Socket { for (let [idx, middleware] of middlewares.entries()) { await middleware(ctx, () => { if (idx === middlewares.length - 1) { - // Middlewares are finished. + // Middlewares are finished // Extract some data from our enriched koa context to persist // as metadata for the socket - socket.data.user = { - id: ctx.user._id, - email: ctx.user.email, + + // Add user info, including a deterministic color and friendly + // label + const { _id, email, firstName, lastName } = ctx.user + let hue = 1 + for (let i = 0; i < email.length && i < 5; i++) { + hue *= email.charCodeAt(i + 1) + hue /= 17 } + hue = hue % 360 + const color = `hsl(${hue}, 50%, 40%)` + let label = email + if (firstName) { + label = firstName + if (lastName) { + label += ` ${lastName}` + } + } + socket.data.user = { + id: _id, + email, + color, + label, + } + + // Add app ID to help split sockets into rooms + socket.data.appId = ctx.appId next() } })