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()
}
})