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,
)} +