diff --git a/editor/src/components/canvas/controls/comment-indicator.tsx b/editor/src/components/canvas/controls/comment-indicator.tsx
index a1420ce0d07e..3fc9189c3d4a 100644
--- a/editor/src/components/canvas/controls/comment-indicator.tsx
+++ b/editor/src/components/canvas/controls/comment-indicator.tsx
@@ -1,11 +1,11 @@
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from '@emotion/react'
-import type { CSSObject, Interpolation } from '@emotion/react'
+import type { Interpolation } from '@emotion/react'
import type { ThreadData } from '@liveblocks/client'
import React from 'react'
import type { ThreadMetadata } from '../../../../liveblocks.config'
-import { useEditThreadMetadata, useStorage, useThreads } from '../../../../liveblocks.config'
+import { useEditThreadMetadata, useStorage } from '../../../../liveblocks.config'
import {
useCanvasLocationOfThread,
useActiveThreads,
@@ -40,6 +40,7 @@ import { optionalMap } from '../../../core/shared/optional-utils'
import { setRightMenuTab } from '../../editor/actions/action-creators'
import { RightMenuTab, getCurrentTheme } from '../../editor/store/editor-state'
import { when } from '../../../utils/react-conditionals'
+import { CommentRepliesCounter } from './comment-replies-counter'
const IndicatorSize = 24
const MagnifyScale = 1.15
@@ -452,6 +453,12 @@ const HoveredCommentIndicator = React.memo((props: HoveredCommentIndicatorProps)
return (
)
})
diff --git a/editor/src/components/canvas/controls/comment-popup.tsx b/editor/src/components/canvas/controls/comment-popup.tsx
index 8c91a30c2db3..0f51d4c57e44 100644
--- a/editor/src/components/canvas/controls/comment-popup.tsx
+++ b/editor/src/components/canvas/controls/comment-popup.tsx
@@ -273,7 +273,8 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => {
return
}
resolveThread(thread)
- }, [thread, resolveThread])
+ dispatch([switchEditorMode(EditorModes.commentMode(null, 'not-dragging'))])
+ }, [thread, resolveThread, dispatch])
const onClickClose = React.useCallback(() => {
dispatch([switchEditorMode(EditorModes.commentMode(null, 'not-dragging'))])
diff --git a/editor/src/components/canvas/controls/comment-replies-counter.tsx b/editor/src/components/canvas/controls/comment-replies-counter.tsx
new file mode 100644
index 000000000000..64ffe3a137b4
--- /dev/null
+++ b/editor/src/components/canvas/controls/comment-replies-counter.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import type { ThreadData } from '@liveblocks/client'
+import type { ThreadMetadata } from '../../../../liveblocks.config'
+import { useColorTheme } from '../../../uuiui'
+
+interface CommentRepliesCounterProps {
+ thread: ThreadData
+}
+
+export const CommentRepliesCounter = React.memo((props: CommentRepliesCounterProps) => {
+ const colorTheme = useColorTheme()
+
+ const repliesCount = props.thread.comments.filter((c) => c.deletedAt == null).length - 1
+
+ if (repliesCount <= 0) {
+ return
+ }
+
+ return (
+
+ {repliesCount} {repliesCount > 1 ? 'replies' : 'reply'}
+
+ )
+})
+CommentRepliesCounter.displayName = 'CommentRepliesCounter'
diff --git a/editor/src/components/canvas/controls/select-mode/select-mode.spec.browser2.tsx b/editor/src/components/canvas/controls/select-mode/select-mode.spec.browser2.tsx
index 2ab1a120c856..69516e4a8609 100644
--- a/editor/src/components/canvas/controls/select-mode/select-mode.spec.browser2.tsx
+++ b/editor/src/components/canvas/controls/select-mode/select-mode.spec.browser2.tsx
@@ -1,4 +1,4 @@
-/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "checkFocusedPath", "checkSelectedPaths"] }] */
+/* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "checkFocusedPath", "checkSelectedPaths", "selectInjectedDivAndCheckAllPaths"] }] */
///
import { BakedInStoryboardUID } from '../../../../core/model/scene-utils'
import * as EP from '../../../../core/shared/element-path'
@@ -1736,6 +1736,86 @@ describe('mouseup selection', () => {
})
})
+describe('Problematic selection', () => {
+ // This was a real world bug caused by the unholy combination of these things
+
+ async function selectInjectedDivAndCheckAllPaths(
+ renderResult: EditorRenderResult,
+ injectedDiv: HTMLElement,
+ desiredPaths: Array,
+ ) {
+ const injectedDivBounds = injectedDiv.getBoundingClientRect()
+
+ const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const doubleClick = createDoubleClicker(
+ canvasControlsLayer,
+ injectedDivBounds.left + injectedDivBounds.width / 2,
+ injectedDivBounds.top + injectedDivBounds.height / 2,
+ )
+
+ await fireSingleClickEvents(
+ canvasControlsLayer,
+ injectedDivBounds.left + injectedDivBounds.width / 2,
+ injectedDivBounds.top + injectedDivBounds.height / 2,
+ )
+
+ checkFocusedPath(renderResult, null)
+ checkSelectedPaths(renderResult, [desiredPaths[0]])
+
+ await doubleClick()
+ checkFocusedPath(renderResult, null)
+ checkSelectedPaths(renderResult, [desiredPaths[1]])
+
+ await doubleClick()
+ checkFocusedPath(renderResult, desiredPaths[1])
+ checkSelectedPaths(renderResult, [desiredPaths[1]])
+
+ await doubleClick()
+ checkFocusedPath(renderResult, desiredPaths[1])
+ checkSelectedPaths(renderResult, [desiredPaths[2]])
+ }
+
+ it('Can keep clicking to select the parent of a dangerouslySetInnerHTML element, inside a component with a fragment', async () => {
+ // prettier-ignore
+ const desiredPaths = createConsecutivePaths(
+ 'sb' + // Skipped as it's the storyboard
+ '/div-a', // Single click to select
+ '/FragmentAtRoot', // Double click to select, double click again to focus
+ ':FragmentAtRoot-root' + // Skipped as it's locked
+ '/FragmentAtRoot-target' // Third double click to select
+ )
+
+ const renderResult = await renderTestEditorWithCode(
+ DangerouslySetInnerHTMLProject,
+ 'await-first-dom-report',
+ )
+
+ const injectedDiv = renderResult.renderedDOM.getAllByTestId('injected-div')[0]
+
+ await selectInjectedDivAndCheckAllPaths(renderResult, injectedDiv, desiredPaths)
+ })
+
+ it('Can keep clicking to select the parent of a dangerouslySetInnerHTML element, inside a component with a div', async () => {
+ // prettier-ignore
+ const desiredPaths = createConsecutivePaths(
+ 'sb' + // Skipped as it's the storyboard
+ '/div-b', // Single click to select
+ '/DivAtRoot', // Double click to select, double click again to focus
+ ':DivAtRoot-root' + // Skipped as it's locked
+ '/DivAtRoot-target' // Third double click
+ )
+
+ const renderResult = await renderTestEditorWithCode(
+ DangerouslySetInnerHTMLProject,
+ 'await-first-dom-report',
+ )
+
+ const injectedDiv = renderResult.renderedDOM.getAllByTestId('injected-div')[1]
+
+ await selectInjectedDivAndCheckAllPaths(renderResult, injectedDiv, desiredPaths)
+ })
+})
+
function createConsecutivePaths(...partialPathStrings: Array): Array {
return partialPathStrings.map((_value, index, arr) => {
const joinedParts = arr.slice(0, index + 1).join('')
@@ -1761,6 +1841,80 @@ function checkWithKey(key: string, actual: T, expected: T) {
})
}
+const DangerouslySetInnerHTMLProject = `
+import * as React from 'react'
+import { Scene, Storyboard } from 'utopia-api'
+
+const content =
+ 'Click me if you can!
'
+
+const UnSelectable = () => {
+ return (
+
+ You can't select this
+
+
+ )
+}
+
+const Selectable = () => {
+ return (
+
+ You can select this
+
+
+ )
+}
+
+const Card = (props) => {
+ return {props.children}
+}
+
+export var storyboard = (
+
+
+
+
+
+
+
+
+)
+`
+
const generateTestProjectAlpineClimb = (conditional: boolean, conditionalSiblings: boolean) => `
import * as React from "react";
import { Scene, Storyboard } from "utopia-api";
diff --git a/editor/src/components/canvas/design-panel-root.tsx b/editor/src/components/canvas/design-panel-root.tsx
index 3fe8f11f19a0..b50fff76cfac 100644
--- a/editor/src/components/canvas/design-panel-root.tsx
+++ b/editor/src/components/canvas/design-panel-root.tsx
@@ -36,8 +36,8 @@ import { useRoom, useStatus } from '../../../liveblocks.config'
import { MultiplayerWrapper } from '../../utils/multiplayer-wrapper'
import { isFeatureEnabled } from '../../utils/feature-switches'
import { CommentsPane } from '../inspector/comments-pane'
-import { useIsViewer } from '../editor/store/project-server-state-hooks'
import { EditorModes, isCommentMode } from '../editor/editor-modes'
+import { useAllowedToEditProject } from '../editor/store/collaborative-editing'
function isCodeEditorEnabled(): boolean {
if (typeof window !== 'undefined') {
@@ -170,7 +170,7 @@ export const RightPane = React.memo((props) => {
onClickTab(RightMenuTab.Settings)
}, [onClickTab])
- const isViewer = useIsViewer()
+ const allowedToEdit = useAllowedToEditProject()
if (!isRightMenuExpanded) {
return null
@@ -196,8 +196,8 @@ export const RightPane = React.memo((props) => {
selected={selectedTab === RightMenuTab.Inspector}
onClick={onClickInspectorTab}
/>
- {unless(
- isViewer,
+ {when(
+ allowedToEdit,
<>
{
roomStatus !== 'connected'
? CommentModeButtonTestId('disconnected')
: CommentModeButtonTestId('connected')
- const isViewer = useIsViewer()
+ const allowedToEdit = useAllowedToEditProject()
return (
{
style={{ width: 36 }}
/>
- {unless(
- isViewer,
+ {when(
+ allowedToEdit,
<>
{
{when(
canvasToolbarMode.primary === 'edit' &&
canvasToolbarMode.secondary === 'selected' &&
- !isViewer,
+ allowedToEdit,
<>
{when(
insertMenuMode === 'closed',
@@ -677,7 +677,7 @@ export const CanvasToolbar = React.memo(() => {
{when(
canvasToolbarMode.primary === 'edit' &&
canvasToolbarMode.secondary === 'strategy-active' &&
- !isViewer,
+ allowedToEdit,
,
)}
{/* Insert Mode */}
diff --git a/editor/src/components/editor/editor-component.tsx b/editor/src/components/editor/editor-component.tsx
index 909cedce4a98..30f02ea9c68f 100644
--- a/editor/src/components/editor/editor-component.tsx
+++ b/editor/src/components/editor/editor-component.tsx
@@ -59,11 +59,7 @@ import { useDispatch } from './store/dispatch-context'
import type { EditorAction } from './action-types'
import { EditorCommon } from './editor-component-common'
import { notice } from '../common/notice'
-import {
- ProjectServerStateUpdater,
- isProjectViewer,
- isProjectViewerFromState,
-} from './store/project-server-state'
+import { ProjectServerStateUpdater } from './store/project-server-state'
import { RoomProvider, initialPresence, useRoom, initialStorage } from '../../../liveblocks.config'
import { generateUUID } from '../../utils/utils'
import { isLiveblocksEnabled } from './liveblocks-utils'
@@ -72,6 +68,7 @@ import LiveblocksProvider from '@liveblocks/yjs'
import { isRoomId, projectIdToRoomId } from '../../core/shared/multiplayer'
import { useDisplayOwnershipWarning } from './project-owner-hooks'
import { EditorModes } from './editor-modes'
+import { allowedToEditProject } from './store/collaborative-editing'
const liveModeToastId = 'play-mode-toast'
@@ -359,9 +356,9 @@ export const EditorComponentInner = React.memo((props: EditorProps) => {
useSelectorWithCallback(
Substores.projectServerState,
- (store) => store.projectServerState.isMyProject,
+ (store) => store.projectServerState,
(isMyProject) => {
- if (isProjectViewer(isMyProject)) {
+ if (!allowedToEditProject(isMyProject)) {
dispatch([
EditorActions.switchEditorMode(EditorModes.commentMode(null, 'not-dragging')),
EditorActions.setRightMenuTab(RightMenuTab.Comments),
diff --git a/editor/src/components/editor/global-shortcuts.tsx b/editor/src/components/editor/global-shortcuts.tsx
index 8322501a93ae..4c1d8f7a5fb1 100644
--- a/editor/src/components/editor/global-shortcuts.tsx
+++ b/editor/src/components/editor/global-shortcuts.tsx
@@ -146,7 +146,8 @@ import type { ElementPathTrees } from '../../core/shared/element-path-tree'
import { createPasteToReplacePostActionActions } from '../canvas/canvas-strategies/post-action-options/post-action-options'
import { wrapInDivStrategy } from './wrap-in-callbacks'
import { isFeatureEnabled } from '../../utils/feature-switches'
-import { isProjectViewerFromState, type ProjectServerState } from './store/project-server-state'
+import { type ProjectServerState } from './store/project-server-state'
+import { allowedToEditProject } from './store/collaborative-editing'
function updateKeysPressed(
keysPressed: KeysPressed,
@@ -364,7 +365,7 @@ export function handleKeyDown(
// Stop the browser from firing things like save dialogs.
preventBrowserShortcuts(editor, event)
- const isViewer = isProjectViewerFromState(projectServerState)
+ const allowedToEdit = allowedToEditProject(projectServerState)
// Ensure that any key presses are appropriately recorded.
const key = Keyboard.keyCharacterForCode(event.keyCode)
@@ -612,12 +613,11 @@ export function handleKeyDown(
return [EditorActions.toggleHidden()]
},
[INSERT_IMAGE_SHORTCUT]: () => {
- if (isViewer) {
- return []
- }
- if (isSelectMode(editor.mode) || isInsertMode(editor.mode)) {
- // FIXME: Side effects.
- insertImage(dispatch)
+ if (allowedToEdit) {
+ if (isSelectMode(editor.mode) || isInsertMode(editor.mode)) {
+ // Side effects.
+ insertImage(dispatch)
+ }
}
return []
},
@@ -752,20 +752,18 @@ export function handleKeyDown(
}
},
[ADD_ELEMENT_SHORTCUT]: () => {
- if (isViewer) {
- return []
- }
- if (isSelectMode(editor.mode)) {
- return [
- EditorActions.openFloatingInsertMenu({
- insertMenuMode: 'insert',
- parentPath: null,
- indexPosition: null,
- }),
- ]
- } else {
- return []
+ if (allowedToEdit) {
+ if (isSelectMode(editor.mode)) {
+ return [
+ EditorActions.openFloatingInsertMenu({
+ insertMenuMode: 'insert',
+ parentPath: null,
+ indexPosition: null,
+ }),
+ ]
+ }
}
+ return []
},
[OPEN_EYEDROPPER]: () => {
const selectedViews = editor.selectedViews
@@ -788,30 +786,29 @@ export function handleKeyDown(
return []
},
[TEXT_EDIT_MODE]: () => {
- if (isViewer) {
- return []
- }
- const newUID = generateUidWithExistingComponents(editor.projectContents)
+ if (allowedToEdit) {
+ const newUID = generateUidWithExistingComponents(editor.projectContents)
- actions.push(
- EditorActions.enableInsertModeForJSXElement(
- defaultSpanElement(newUID),
- newUID,
- {},
- null,
- {
- textEdit: true,
- },
- ),
- CanvasActions.createInteractionSession(
- createHoverInteractionViaMouse(
- CanvasMousePositionRaw!,
- modifiers,
- boundingArea(),
- 'zero-drag-permitted',
+ actions.push(
+ EditorActions.enableInsertModeForJSXElement(
+ defaultSpanElement(newUID),
+ newUID,
+ {},
+ null,
+ {
+ textEdit: true,
+ },
),
- ),
- )
+ CanvasActions.createInteractionSession(
+ createHoverInteractionViaMouse(
+ CanvasMousePositionRaw!,
+ modifiers,
+ boundingArea(),
+ 'zero-drag-permitted',
+ ),
+ ),
+ )
+ }
return actions
},
[TOGGLE_TEXT_BOLD]: () => {
@@ -965,13 +962,14 @@ export function handleKeyDown(
return [EditorActions.applyCommandsAction(commands)]
},
[OPEN_INSERT_MENU]: () => {
- if (isViewer) {
+ if (allowedToEdit) {
+ return [
+ EditorActions.setPanelVisibility('rightmenu', true),
+ EditorActions.setRightMenuTab(RightMenuTab.Insert),
+ ]
+ } else {
return []
}
- return [
- EditorActions.setPanelVisibility('rightmenu', true),
- EditorActions.setRightMenuTab(RightMenuTab.Insert),
- ]
},
[WRAP_IN_DIV]: () => {
if (!isSelectMode(editor.mode)) {
diff --git a/editor/src/components/editor/persistence/persistence.ts b/editor/src/components/editor/persistence/persistence.ts
index 6d4e244799ba..f6a5244488a7 100644
--- a/editor/src/components/editor/persistence/persistence.ts
+++ b/editor/src/components/editor/persistence/persistence.ts
@@ -27,6 +27,7 @@ import {
userLogOutEvent,
} from './generic/persistence-machine'
import type { PersistenceBackendAPI, PersistenceContext } from './generic/persistence-types'
+import { releaseControl } from '../store/collaborative-editing'
export class PersistenceMachine {
private interpreter: Interpreter<
@@ -133,8 +134,10 @@ export class PersistenceMachine {
this.interpreter.start()
- window.addEventListener('beforeunload', (e) => {
- if (!this.isSafeToClose()) {
+ window.addEventListener('beforeunload', async (e) => {
+ if (this.isSafeToClose()) {
+ void releaseControl()
+ } else {
this.sendThrottledSave()
e.preventDefault()
e.returnValue = ''
diff --git a/editor/src/components/editor/project-owner-hooks.ts b/editor/src/components/editor/project-owner-hooks.ts
index eab093a24e2a..b2a0853db3ed 100644
--- a/editor/src/components/editor/project-owner-hooks.ts
+++ b/editor/src/components/editor/project-owner-hooks.ts
@@ -1,15 +1,14 @@
import * as React from 'react'
import { useDispatch } from './store/dispatch-context'
import { Substores, useSelectorWithCallback } from './store/store-hook'
-import type { ProjectServerState } from './store/project-server-state'
import { removeToast, showToast } from './actions/action-creators'
import { notice } from '../common/notice'
-import { assertNever } from '../../core/shared/utils'
+import { allowedToEditProject } from './store/collaborative-editing'
const OwnershipToastID = 'project-ownership-toast'
interface OwnershipValues {
- projectOwnership: ProjectServerState['isMyProject']
+ allowedToEdit: boolean
projectID: string | null
}
@@ -21,33 +20,25 @@ export function useDisplayOwnershipWarning(): void {
// Without this check this hook will only fire before the LOAD action
// and then the toasts will be cleared before they ever really existed.
if (ownershipValues.projectID != null) {
- switch (ownershipValues.projectOwnership) {
- case 'yes':
- // Remove the toast if we switch to a project that the user owns.
- globalThis.requestAnimationFrame(() => {
- dispatch([removeToast(OwnershipToastID)])
- })
- break
- case 'no':
- // Add the toast if we switch to a project that the user does not own.
- globalThis.requestAnimationFrame(() => {
- dispatch([
- showToast(
- notice(
- 'Viewer Mode: As you are not the owner of this project, it is read-only.',
- 'NOTICE',
- true,
- OwnershipToastID,
- ),
+ if (ownershipValues.allowedToEdit) {
+ // Remove the toast if we switch to a project that the user owns.
+ globalThis.requestAnimationFrame(() => {
+ dispatch([removeToast(OwnershipToastID)])
+ })
+ } else {
+ // Add the toast if we switch to a project that the user does not own.
+ globalThis.requestAnimationFrame(() => {
+ dispatch([
+ showToast(
+ notice(
+ 'Viewer Mode: Either you are not the owner of this project or you have this project open elsewhere. As a result it is read-only.',
+ 'NOTICE',
+ true,
+ OwnershipToastID,
),
- ])
- })
- break
- case 'unknown':
- // Do nothing.
- break
- default:
- assertNever(ownershipValues.projectOwnership)
+ ),
+ ])
+ })
}
}
},
@@ -57,7 +48,7 @@ export function useDisplayOwnershipWarning(): void {
Substores.fullStore,
(store) => {
return {
- projectOwnership: store.projectServerState.isMyProject,
+ allowedToEdit: allowedToEditProject(store.projectServerState),
projectID: store.editor.id,
}
},
diff --git a/editor/src/components/editor/store/collaborative-editing.ts b/editor/src/components/editor/store/collaborative-editing.ts
index 8930a56a1435..45908f302d83 100644
--- a/editor/src/components/editor/store/collaborative-editing.ts
+++ b/editor/src/components/editor/store/collaborative-editing.ts
@@ -21,12 +21,11 @@ import {
} from '../../../components/assets'
import type { EditorAction, EditorDispatch } from '../action-types'
import { updateTopLevelElementsFromCollaborationUpdate } from '../actions/action-creators'
-import { assertNever, isBrowserEnvironment } from '../../../core/shared/utils'
+import { assertNever } from '../../../core/shared/utils'
import type {
ExportDetail,
ImportDetails,
ParseSuccess,
- ParsedTextFile,
} from '../../../core/shared/project-file-types'
import { isTextFile } from '../../../core/shared/project-file-types'
import {
@@ -36,18 +35,22 @@ import {
TopLevelElementKeepDeepEquality,
} from './store-deep-equality-instances'
import type { WriteProjectFileChange } from './vscode-changes'
+import { v4 as UUID } from 'uuid'
import {
deletePathChange,
ensureDirectoryExistsChange,
writeProjectFileChange,
- type ProjectChanges,
type ProjectFileChange,
} from './vscode-changes'
-import { unparsedCode, type TopLevelElement } from '../../../core/shared/element-template'
+import { type TopLevelElement } from '../../../core/shared/element-template'
import { isFeatureEnabled } from '../../../utils/feature-switches'
import type { KeepDeepEqualityCall } from '../../../utils/deep-equality'
import type { MapLike } from 'typescript'
import { Y } from '../../../core/shared/yjs'
+import { HEADERS, MODE } from '../../../common/server'
+import { UTOPIA_BACKEND } from '../../../common/env-vars'
+import type { ProjectServerState } from './project-server-state'
+import { Substores, useEditorState } from './store-hook'
const CodeKey = 'code'
const TopLevelElementsKey = 'topLevelElements'
@@ -609,3 +612,108 @@ function synchroniseParseSuccessToCollabFile(
ImportDetailsKeepDeepEquality,
)
}
+
+export interface ClaimProjectControl {
+ type: 'CLAIM_PROJECT_CONTROL'
+ projectID: string
+ collaborationEditor: string
+}
+
+export function claimProjectControl(
+ projectID: string,
+ collaborationEditor: string,
+): ClaimProjectControl {
+ return {
+ type: 'CLAIM_PROJECT_CONTROL',
+ projectID: projectID,
+ collaborationEditor: collaborationEditor,
+ }
+}
+
+export interface ClearAllOfCollaboratorsControl {
+ type: 'CLEAR_ALL_OF_COLLABORATORS_CONTROL'
+ collaborationEditor: string
+}
+
+export function clearAllOfCollaboratorsControl(
+ collaborationEditor: string,
+): ClearAllOfCollaboratorsControl {
+ return {
+ type: 'CLEAR_ALL_OF_COLLABORATORS_CONTROL',
+ collaborationEditor: collaborationEditor,
+ }
+}
+
+export type CollaborationRequest = ClaimProjectControl | ClearAllOfCollaboratorsControl
+
+export interface ClaimProjectControlResult {
+ type: 'CLAIM_PROJECT_CONTROL_RESULT'
+ successfullyClaimed: boolean
+}
+
+export interface ClearAllOfCollaboratorsControlResult {
+ type: 'CLEAR_ALL_OF_COLLABORATORS_CONTROL_RESULT'
+}
+
+export type CollaborationResponse = ClaimProjectControlResult | ClearAllOfCollaboratorsControlResult
+
+const collaborationEditor = UUID()
+
+async function callCollaborationEndpoint(
+ request: CollaborationRequest,
+): Promise {
+ const url = `${UTOPIA_BACKEND}collaboration`
+ const response = await fetch(url, {
+ method: 'PUT',
+ credentials: 'include',
+ headers: HEADERS,
+ mode: MODE,
+ body: JSON.stringify(request),
+ })
+ if (response.ok) {
+ return response.json()
+ } else {
+ throw new Error(`server responded with ${response.status} ${response.statusText}`)
+ }
+}
+
+export async function claimControlOverProject(projectID: string | null): Promise {
+ if (projectID == null || !isFeatureEnabled('Baton Passing For Control')) {
+ return null
+ }
+
+ const request = claimProjectControl(projectID, collaborationEditor)
+ const response = await callCollaborationEndpoint(request)
+ if (response.type === 'CLAIM_PROJECT_CONTROL_RESULT') {
+ return response.successfullyClaimed
+ } else {
+ throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
+ }
+}
+
+export async function releaseControl(): Promise {
+ if (isFeatureEnabled('Baton Passing For Control')) {
+ const request = clearAllOfCollaboratorsControl(collaborationEditor)
+
+ const response = await callCollaborationEndpoint(request)
+ if (response.type !== 'CLEAR_ALL_OF_COLLABORATORS_CONTROL_RESULT') {
+ throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
+ }
+ }
+}
+
+export function allowedToEditProject(serverState: ProjectServerState): boolean {
+ if (isFeatureEnabled('Baton Passing For Control')) {
+ return serverState.currentlyHolderOfTheBaton
+ } else {
+ return serverState.isMyProject === 'yes'
+ }
+}
+
+export function useAllowedToEditProject(): boolean {
+ return useEditorState(
+ Substores.projectServerState,
+ (store) => allowedToEditProject(store.projectServerState),
+ 'useAllowedToEditProject',
+ )
+}
diff --git a/editor/src/components/editor/store/dispatch-strategies.tsx b/editor/src/components/editor/store/dispatch-strategies.tsx
index 085eea03c5f2..8ce54f61e81f 100644
--- a/editor/src/components/editor/store/dispatch-strategies.tsx
+++ b/editor/src/components/editor/store/dispatch-strategies.tsx
@@ -55,7 +55,7 @@ import { last } from '../../../core/shared/array-utils'
import type { BuiltInDependencies } from '../../../core/es-modules/package-manager/built-in-dependencies-list'
import { isInsertMode } from '../editor-modes'
import { patchedCreateRemixDerivedDataMemo } from './remix-derived-data'
-import { isProjectViewerFromState } from './project-server-state'
+import { allowedToEditProject } from './collaborative-editing'
interface HandleStrategiesResult {
unpatchedEditorState: EditorState
@@ -658,11 +658,7 @@ export function handleStrategies(
let unpatchedEditorState: EditorState
let patchedEditorState: EditorState
let newStrategyState: StrategyState
- if (isProjectViewerFromState(storedState.projectServerState)) {
- unpatchedEditorState = result.unpatchedEditor
- patchedEditorState = result.unpatchedEditor
- newStrategyState = result.strategyState
- } else {
+ if (allowedToEditProject(storedState.projectServerState)) {
const strategiesResult = handleStrategiesInner(
strategies,
dispatchedActions,
@@ -672,6 +668,10 @@ export function handleStrategies(
unpatchedEditorState = strategiesResult.unpatchedEditorState
patchedEditorState = strategiesResult.patchedEditorState
newStrategyState = strategiesResult.newStrategyState
+ } else {
+ unpatchedEditorState = result.unpatchedEditor
+ patchedEditorState = result.unpatchedEditor
+ newStrategyState = result.strategyState
}
const patchedEditorWithMetadata: EditorState = {
diff --git a/editor/src/components/editor/store/editor-update.tsx b/editor/src/components/editor/store/editor-update.tsx
index edf9edfae4e5..0eb19214485a 100644
--- a/editor/src/components/editor/store/editor-update.tsx
+++ b/editor/src/components/editor/store/editor-update.tsx
@@ -21,6 +21,7 @@ import type { BuiltInDependencies } from '../../../core/es-modules/package-manag
import { foldAndApplyCommandsSimple } from '../../canvas/commands/commands'
import type { ProjectServerState } from './project-server-state'
import { isTransientAction } from '../actions/action-utils'
+import { allowedToEditProject } from './collaborative-editing'
export function runLocalEditorAction(
state: EditorState,
@@ -87,18 +88,16 @@ export function gatedActions(
break
}
+ const canEditProject = allowedToEditProject(serverState)
+
if (alwaysRun) {
// If it should always run.
return updateCallback()
- } else if (isCollaborationUpdate && serverState.isMyProject === 'yes') {
+ } else if (isCollaborationUpdate && canEditProject) {
// If this action is something that would've originated with an owner,
// it shouldn't run on an owner.
return defaultValue
- } else if (
- !isTransientAction(action) &&
- serverState.isMyProject == 'no' &&
- !isCollaborationUpdate
- ) {
+ } else if (!isTransientAction(action) && !canEditProject && !isCollaborationUpdate) {
// If this is a change that will modify the project contents, the current user
// is not the owner and it's not a collaboration update (those intended directly
// for viewers in a collaboration session) do not run the action.
diff --git a/editor/src/components/editor/store/project-server-state-hooks.tsx b/editor/src/components/editor/store/project-server-state-hooks.tsx
deleted file mode 100644
index ee95abaafb27..000000000000
--- a/editor/src/components/editor/store/project-server-state-hooks.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { isProjectViewerFromState } from './project-server-state'
-import { Substores, useEditorState } from './store-hook'
-
-export function useIsViewer(): boolean {
- const isViewer = useEditorState(
- Substores.projectServerState,
- (store) => isProjectViewerFromState(store.projectServerState),
- 'useIsViewer isViewer',
- )
- return isViewer
-}
diff --git a/editor/src/components/editor/store/project-server-state.tsx b/editor/src/components/editor/store/project-server-state.tsx
index 4fdf2416cd98..d8422ed9647f 100644
--- a/editor/src/components/editor/store/project-server-state.tsx
+++ b/editor/src/components/editor/store/project-server-state.tsx
@@ -6,6 +6,8 @@ import type { EditorDispatch } from '../action-types'
import { updateProjectServerState } from '../actions/action-creators'
import { checkProjectOwned } from '../persistence/persistence-backend'
import type { ProjectOwnership } from '../persistence/generic/persistence-types'
+import { isFeatureEnabled } from '../../../utils/feature-switches'
+import { claimControlOverProject } from './collaborative-editing'
export interface ProjectMetadataFromServer {
title: string
@@ -32,6 +34,7 @@ export interface ProjectServerState {
ownerId: string | null
projectData: ProjectMetadataFromServer | null
forkedFromProjectData: ProjectMetadataFromServer | null
+ currentlyHolderOfTheBaton: boolean
}
export function projectServerState(
@@ -39,17 +42,19 @@ export function projectServerState(
ownerId: string | null,
projectData: ProjectMetadataFromServer | null,
forkedFromProjectData: ProjectMetadataFromServer | null,
+ currentlyHolderOfTheBaton: boolean,
): ProjectServerState {
return {
isMyProject: isMyProject,
ownerId: ownerId,
projectData: projectData,
forkedFromProjectData: forkedFromProjectData,
+ currentlyHolderOfTheBaton: currentlyHolderOfTheBaton,
}
}
export function emptyProjectServerState(): ProjectServerState {
- return projectServerState('unknown', null, null, null)
+ return projectServerState('unknown', null, null, null, false)
}
export const ProjectServerStateContext = React.createContext(
@@ -90,11 +95,14 @@ export async function getProjectServerState(
}
const ownership = await getOwnership()
+ const holderOfTheBaton = (await claimControlOverProject(projectId)) ?? ownership.isOwner
+
return {
isMyProject: ownership.isOwner ? 'yes' : 'no',
ownerId: ownership.ownerId,
projectData: projectListingToProjectMetadataFromServer(projectListing),
forkedFromProjectData: projectListingToProjectMetadataFromServer(forkedFromProjectListing),
+ currentlyHolderOfTheBaton: holderOfTheBaton,
}
}
}
@@ -105,26 +113,38 @@ export interface ProjectServerStateUpdaterProps {
dispatch: EditorDispatch
}
+let serverStateWatcherInstance: number | null = null
+function restartServerStateWatcher(
+ projectId: string | null,
+ forkedFromProjectId: string | null,
+ dispatch: EditorDispatch,
+): void {
+ function updateServerState(): void {
+ void getProjectServerState(projectId, forkedFromProjectId)
+ .then((serverState) => {
+ dispatch([updateProjectServerState(serverState)], 'everyone')
+ })
+ .catch((error) => {
+ console.error('Error while updating server state.', error)
+ })
+ }
+ if (isFeatureEnabled('Baton Passing For Control')) {
+ if (serverStateWatcherInstance != null) {
+ window.clearInterval(serverStateWatcherInstance)
+ }
+ updateServerState()
+ serverStateWatcherInstance = window.setInterval(updateServerState, 10 * 1000)
+ } else {
+ updateServerState()
+ }
+}
+
export const ProjectServerStateUpdater = React.memo(
(props: React.PropsWithChildren) => {
const { projectId, forkedFromProjectId, dispatch, children } = props
React.useEffect(() => {
- void getProjectServerState(projectId, forkedFromProjectId)
- .then((serverState) => {
- dispatch([updateProjectServerState(serverState)], 'everyone')
- })
- .catch((error) => {
- console.error('Error while updating server state.', error)
- })
+ restartServerStateWatcher(projectId, forkedFromProjectId, dispatch)
}, [dispatch, projectId, forkedFromProjectId])
return <>{children}>
},
)
-
-export function isProjectViewerFromState(state: ProjectServerState): boolean {
- return isProjectViewer(state.isMyProject)
-}
-
-export function isProjectViewer(isMyProject: IsMyProject): boolean {
- return isMyProject === 'no'
-}
diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts
index 43d7bb1d0539..c54c6bacd791 100644
--- a/editor/src/components/editor/store/store-deep-equality-instances.ts
+++ b/editor/src/components/editor/store/store-deep-equality-instances.ts
@@ -563,7 +563,7 @@ export const ProjectMetadataFromServerKeepDeepEquality: KeepDeepEqualityCall =
- combine4EqualityCalls(
+ combine5EqualityCalls(
(entry) => entry.isMyProject,
createCallWithTripleEquals(),
(entry) => entry.ownerId,
@@ -572,6 +572,8 @@ export const ProjectServerStateKeepDeepEquality: KeepDeepEqualityCall entry.forkedFromProjectData,
nullableDeepEquality(ProjectMetadataFromServerKeepDeepEquality),
+ (entry) => entry.currentlyHolderOfTheBaton,
+ createCallWithTripleEquals(),
projectServerState,
)
diff --git a/editor/src/components/inspector/sections/comment-section.tsx b/editor/src/components/inspector/sections/comment-section.tsx
index 54448996c50a..0d474ef0e6ce 100644
--- a/editor/src/components/inspector/sections/comment-section.tsx
+++ b/editor/src/components/inspector/sections/comment-section.tsx
@@ -35,13 +35,14 @@ import {
useMyThreadReadStatus,
} from '../../../core/commenting/comment-hooks'
import { Substores, useEditorState, useSelectorWithCallback } from '../../editor/store/store-hook'
-import { unless, when } from '../../../utils/react-conditionals'
+import { when } from '../../../utils/react-conditionals'
import { openCommentThreadActions } from '../../../core/shared/multiplayer'
import { getRemixLocationLabel } from '../../canvas/remix/remix-utils'
import type { RestOfEditorState } from '../../editor/store/store-hook-substore-types'
import { getCurrentTheme } from '../../editor/store/editor-state'
import type { EditorAction } from '../../editor/action-types'
import { canvasPointToWindowPoint } from '../../canvas/dom-lookup'
+import { CommentRepliesCounter } from '../../canvas/controls/comment-replies-counter'
export const CommentSection = React.memo(() => {
return (
@@ -219,8 +220,6 @@ const ThreadPreview = React.memo(({ thread }: ThreadPreviewProps) => {
return null
}
- const repliesCount = thread.comments.filter((c) => c.deletedAt == null).length - 1
-
const remixLocationRouteLabel = getRemixLocationLabel(remixLocationRoute)
const user = getCollaboratorById(collabs, comment.userId)
@@ -288,19 +287,7 @@ const ThreadPreview = React.memo(({ thread }: ThreadPreviewProps) => {
Route: {remixLocationRouteLabel}
,
)}
- {when(
- repliesCount > 0,
-
- {repliesCount} {repliesCount > 1 ? 'replies' : 'reply'}
-
,
- )}
- {unless(repliesCount > 0, )}
+