diff --git a/editor/src/components/canvas/ui-jsx.test-utils.tsx b/editor/src/components/canvas/ui-jsx.test-utils.tsx index c305b71dbc34..79dd1e54bd1b 100644 --- a/editor/src/components/canvas/ui-jsx.test-utils.tsx +++ b/editor/src/components/canvas/ui-jsx.test-utils.tsx @@ -106,7 +106,7 @@ import { treeToContents, } from '../assets' import { testStaticElementPath } from '../../core/shared/element-path.test-utils' -import { createFakeMetadataForParseSuccess } from '../../utils/utils.test-utils' +import { createFakeMetadataForParseSuccess, wait } from '../../utils/utils.test-utils' import { mergeWithPrevUndo, saveDOMReport, @@ -146,7 +146,10 @@ import { carryDispatchResultFields } from './editor-dispatch-flow' import type { FeatureName } from '../../utils/feature-switches' import { setFeatureEnabled } from '../../utils/feature-switches' import { unpatchedCreateRemixDerivedDataMemo } from '../editor/store/remix-derived-data' -import { emptyProjectServerState } from '../editor/store/project-server-state' +import { + emptyProjectServerState, + getUpdateProjectServerStateInStoreRunCount, +} from '../editor/store/project-server-state' // eslint-disable-next-line no-unused-expressions typeof process !== 'undefined' && @@ -626,6 +629,9 @@ label { , ) + // Capture how many times the project server state update has been triggered. + const beforeLoadUpdateStoreRunCount = getUpdateProjectServerStateInStoreRunCount() + await act(async () => { await new Promise((resolve, reject) => { void load( @@ -656,6 +662,15 @@ label { }) }) + // Check that the project server state update has been triggered twice, which (currently at least) + // is the number of times that it runs as a result of the above logic. Once without a project ID and + // the second time with the project ID '0', which should then mean that it has stabilised and unless other + // changes occur the `UPDATE_PROJECT_SERVER_STATE` action shouldn't be triggered anymore after that. + while (getUpdateProjectServerStateInStoreRunCount() <= beforeLoadUpdateStoreRunCount + 1) { + // eslint-disable-next-line no-await-in-loop + await wait(1) + } + return { dispatch: async ( actions: ReadonlyArray, diff --git a/editor/src/components/editor/editor-component.tsx b/editor/src/components/editor/editor-component.tsx index 7e1c75653c49..1cde5686f77d 100644 --- a/editor/src/components/editor/editor-component.tsx +++ b/editor/src/components/editor/editor-component.tsx @@ -67,11 +67,11 @@ import type { Storage, Presence, RoomEvent, UserMeta } from '../../../liveblocks import LiveblocksProvider from '@liveblocks/yjs' import { isRoomId, projectIdToRoomId } from '../../core/shared/multiplayer' import { EditorModes } from './editor-modes' -import { checkIsMyProject } from './store/collaborative-editing' -import { useCanComment, useDataThemeAttributeOnBody } from '../../core/commenting/comment-hooks' +import { useDataThemeAttributeOnBody } from '../../core/commenting/comment-hooks' import { CollaborationStateUpdater } from './store/collaboration-state' import { GithubRepositoryCloneFlow } from '../github/github-repository-clone-flow' import { getPermissions } from './store/permissions' +import { useIsLoggedIn } from '../../core/shared/multiplayer-hooks' const liveModeToastId = 'play-mode-toast' @@ -230,8 +230,8 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { ...handleKeyDown( event, editorStoreRef.current.editor, - editorStoreRef.current.projectServerState, editorStoreRef.current.userState.loginState, + editorStoreRef.current.projectServerState, metadataRef, navigatorTargetsRef, namesByKey, @@ -363,8 +363,6 @@ export const EditorComponentInner = React.memo((props: EditorProps) => { [dispatch], ) - const canComment = useCanComment() - useSelectorWithCallback( Substores.userStateAndProjectServerState, (store) => ({ projectServerState: store.projectServerState, userState: store.userState }), @@ -550,6 +548,8 @@ export function EditorComponent(props: EditorProps) { 'EditorComponent forkedFromProjectId', ) + const loggedIn = useIsLoggedIn() + const dispatch = useDispatch() useDataThemeAttributeOnBody() @@ -572,8 +572,9 @@ export function EditorComponent(props: EditorProps) { projectId={projectId} forkedFromProjectId={forkedFromProjectId} dispatch={dispatch} + loggedIn={loggedIn} > - + diff --git a/editor/src/components/editor/global-shortcuts.tsx b/editor/src/components/editor/global-shortcuts.tsx index a1cdb31b0799..47643b2c506b 100644 --- a/editor/src/components/editor/global-shortcuts.tsx +++ b/editor/src/components/editor/global-shortcuts.tsx @@ -357,8 +357,8 @@ export function preventBrowserShortcuts(editor: EditorState, event: KeyboardEven export function handleKeyDown( event: KeyboardEvent, editor: EditorState, - projectServerState: ProjectServerState, loginState: LoginState, + projectServerState: ProjectServerState, metadataRef: { current: ElementInstanceMetadataMap }, navigatorTargetsRef: { current: Array }, namesByKey: ShortcutNamesByKey, @@ -367,7 +367,7 @@ export function handleKeyDown( // Stop the browser from firing things like save dialogs. preventBrowserShortcuts(editor, event) - const allowedToEdit = allowedToEditProject(projectServerState) + const allowedToEdit = allowedToEditProject(loginState, projectServerState) const canComment = isFeatureEnabled('Multiplayer') && hasCommentPermission(loginState) // Ensure that any key presses are appropriately recorded. diff --git a/editor/src/components/editor/persistence/generic/dummy-persistence-backend.ts b/editor/src/components/editor/persistence/generic/dummy-persistence-backend.ts index 7b024f9c2edb..fe491a037e1d 100644 --- a/editor/src/components/editor/persistence/generic/dummy-persistence-backend.ts +++ b/editor/src/components/editor/persistence/generic/dummy-persistence-backend.ts @@ -14,7 +14,7 @@ function getNewProjectId(): Promise { return Promise.resolve(`Project_${projectCounter++}`) } -function checkProjectOwned(_projectId: string): Promise { +function checkProjectOwned(_loggedIn: boolean, _projectId: string): Promise { return Promise.resolve({ isOwner: true, ownerId: 'the-owner' }) } diff --git a/editor/src/components/editor/persistence/generic/persistence-machine.ts b/editor/src/components/editor/persistence/generic/persistence-machine.ts index 3a3b9c2b2a31..bfcb86410646 100644 --- a/editor/src/components/editor/persistence/generic/persistence-machine.ts +++ b/editor/src/components/editor/persistence/generic/persistence-machine.ts @@ -811,9 +811,9 @@ export function createPersistenceMachine( { services: { getNewProjectId: backendAPI.getNewProjectId, - checkProjectOwned: (_, event) => { + checkProjectOwned: (context, event) => { if (event.type === 'BACKEND_CHECK_OWNERSHIP') { - return backendAPI.checkProjectOwned(event.projectId) + return backendAPI.checkProjectOwned(context.loggedIn, event.projectId) } else { throw new Error( `Incorrect event type triggered check ownership, ${JSON.stringify(event)}`, diff --git a/editor/src/components/editor/persistence/generic/persistence-types.ts b/editor/src/components/editor/persistence/generic/persistence-types.ts index e2c10e32bd9a..c48303196d04 100644 --- a/editor/src/components/editor/persistence/generic/persistence-types.ts +++ b/editor/src/components/editor/persistence/generic/persistence-types.ts @@ -75,7 +75,7 @@ export type ProjectOwnership = { export interface PersistenceBackendAPI { getNewProjectId: () => Promise - checkProjectOwned: (projectId: string) => Promise + checkProjectOwned: (loggedIn: boolean, projectId: string) => Promise loadProject: (projectId: string) => Promise> saveProjectToServer: ( projectId: string, diff --git a/editor/src/components/editor/persistence/persistence-backend.ts b/editor/src/components/editor/persistence/persistence-backend.ts index 2ac116f53716..12025bb2a01d 100644 --- a/editor/src/components/editor/persistence/persistence-backend.ts +++ b/editor/src/components/editor/persistence/persistence-backend.ts @@ -34,6 +34,7 @@ import type { import { fileWithFileName, projectWithFileChanges } from './generic/persistence-types' import type { PersistentModel } from '../store/editor-state' import { isFeatureEnabled } from '../../../utils/feature-switches' +import { IS_TEST_ENVIRONMENT } from '../../../common/env-vars' let _lastThumbnailGenerated: number = 0 const THUMBNAIL_THROTTLE = 300000 @@ -67,18 +68,31 @@ async function getNewProjectId(): Promise { return createNewProjectID() } -export async function checkProjectOwned(projectId: string): Promise { +export async function checkProjectOwned( + loggedIn: boolean, + projectId: string, +): Promise { const existsLocally = await projectIsStoredLocally(projectId) if (existsLocally) { return { ownerId: null, isOwner: true, } - } else { + } else if (loggedIn) { const ownerState = await checkProjectOwnership(projectId) return ownerState === 'unowned' ? { ownerId: null, isOwner: true } : { ownerId: ownerState.ownerId, isOwner: ownerState.isOwner } + } else if (IS_TEST_ENVIRONMENT) { + return { + ownerId: null, + isOwner: true, + } + } else { + return { + ownerId: null, + isOwner: false, + } } } diff --git a/editor/src/components/editor/project-owner-hooks.ts b/editor/src/components/editor/project-owner-hooks.ts index b2a0853db3ed..0da1e012667e 100644 --- a/editor/src/components/editor/project-owner-hooks.ts +++ b/editor/src/components/editor/project-owner-hooks.ts @@ -48,7 +48,7 @@ export function useDisplayOwnershipWarning(): void { Substores.fullStore, (store) => { return { - allowedToEdit: allowedToEditProject(store.projectServerState), + allowedToEdit: allowedToEditProject(store.userState.loginState, store.projectServerState), projectID: store.editor.id, } }, diff --git a/editor/src/components/editor/store/collaboration-state.spec.browser2.tsx b/editor/src/components/editor/store/collaboration-state.spec.browser2.tsx index f2023abeb57c..50fde88bb4d9 100644 --- a/editor/src/components/editor/store/collaboration-state.spec.browser2.tsx +++ b/editor/src/components/editor/store/collaboration-state.spec.browser2.tsx @@ -5,7 +5,8 @@ import { makeTestProjectCodeWithSnippet, renderTestEditorWithCode, } from '../../canvas/ui-jsx.test-utils' -import { updateProjectServerState } from '../actions/action-creators' +import { loggedInUser, notLoggedIn } from '../action-types' +import { setLoginState, updateProjectServerState } from '../actions/action-creators' import { CollaborationEndpoints } from '../collaborative-endpoints' import Sinon from 'sinon' @@ -15,67 +16,149 @@ describe('CollaborationStateUpdater', () => { sandbox.restore() }) - it('snatching control on click should gain control', async () => { - const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') - snatchControlStub.callsFake(async () => { - return true + describe('when logged in', () => { + it('snatching control on click should gain control', async () => { + const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') + snatchControlStub.callsFake(async () => { + return true + }) + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+ `), + 'await-first-dom-report', + ) + await renderResult.dispatch( + [ + setLoginState(loggedInUser({ userId: '1' })), + updateProjectServerState({ + currentlyHolderOfTheBaton: false, + }), + ], + true, + ) + const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) + + const rootRect = canvasRootContainer.getBoundingClientRect() + const rootRectCenter = canvasPoint({ + x: rootRect.x + rootRect.width / 2, + y: rootRect.y + rootRect.height / 2, + }) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + Sinon.assert.calledOnce(snatchControlStub) + expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual( + true, + ) }) - const renderResult = await renderTestEditorWithCode( - makeTestProjectCodeWithSnippet(` + it('snatching control on a bunch of fast clicks should gain control and not make lots of calls to the server', async () => { + const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') + snatchControlStub.callsFake(async () => { + return true + }) + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(`
`), - 'await-first-dom-report', - ) - await renderResult.dispatch( - [ - updateProjectServerState({ - currentlyHolderOfTheBaton: false, - }), - ], - true, - ) - const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) + 'await-first-dom-report', + ) + await renderResult.dispatch( + [ + setLoginState(loggedInUser({ userId: '1' })), + updateProjectServerState({ + currentlyHolderOfTheBaton: false, + }), + ], + true, + ) + const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) - const rootRect = canvasRootContainer.getBoundingClientRect() - const rootRectCenter = canvasPoint({ - x: rootRect.x + rootRect.width / 2, - y: rootRect.y + rootRect.height / 2, + const rootRect = canvasRootContainer.getBoundingClientRect() + const rootRectCenter = canvasPoint({ + x: rootRect.x + rootRect.width / 2, + y: rootRect.y + rootRect.height / 2, + }) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + Sinon.assert.calledOnce(snatchControlStub) + expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual( + true, + ) }) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual(true) }) - it('snatching control on a bunch of fast clicks should gain control and not make lots of calls to the server', async () => { - const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') - snatchControlStub.callsFake(async () => { - return true + + describe('when not logged in', () => { + it('snatching control on click should gain control', async () => { + const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') + snatchControlStub.callsFake(async () => { + return true + }) + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+ `), + 'await-first-dom-report', + ) + await renderResult.dispatch( + [ + setLoginState(notLoggedIn), + updateProjectServerState({ + currentlyHolderOfTheBaton: false, + }), + ], + true, + ) + const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) + + const rootRect = canvasRootContainer.getBoundingClientRect() + const rootRectCenter = canvasPoint({ + x: rootRect.x + rootRect.width / 2, + y: rootRect.y + rootRect.height / 2, + }) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + Sinon.assert.notCalled(snatchControlStub) + expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual( + false, + ) }) - const renderResult = await renderTestEditorWithCode( - makeTestProjectCodeWithSnippet(` + it('snatching control on a bunch of fast clicks should gain control and not make lots of calls to the server', async () => { + const snatchControlStub = sandbox.stub(CollaborationEndpoints, 'snatchControlOverProject') + snatchControlStub.callsFake(async () => { + return true + }) + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(`
`), - 'await-first-dom-report', - ) - await renderResult.dispatch( - [ - updateProjectServerState({ - currentlyHolderOfTheBaton: false, - }), - ], - true, - ) - const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) + 'await-first-dom-report', + ) + await renderResult.dispatch( + [ + setLoginState(notLoggedIn), + updateProjectServerState({ + currentlyHolderOfTheBaton: false, + }), + ], + true, + ) + const canvasRootContainer = renderResult.renderedDOM.getByTestId(CanvasContainerID) - const rootRect = canvasRootContainer.getBoundingClientRect() - const rootRectCenter = canvasPoint({ - x: rootRect.x + rootRect.width / 2, - y: rootRect.y + rootRect.height / 2, + const rootRect = canvasRootContainer.getBoundingClientRect() + const rootRectCenter = canvasPoint({ + x: rootRect.x + rootRect.width / 2, + y: rootRect.y + rootRect.height / 2, + }) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + await mouseClickAtPoint(canvasRootContainer, rootRectCenter) + Sinon.assert.notCalled(snatchControlStub) + expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual( + false, + ) }) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - await mouseClickAtPoint(canvasRootContainer, rootRectCenter) - ;(CollaborationEndpoints.snatchControlOverProject as any).calledOnce - expect(renderResult.getEditorState().projectServerState.currentlyHolderOfTheBaton).toEqual(true) }) }) diff --git a/editor/src/components/editor/store/collaboration-state.tsx b/editor/src/components/editor/store/collaboration-state.tsx index 38c02791d07b..4e078719984b 100644 --- a/editor/src/components/editor/store/collaboration-state.tsx +++ b/editor/src/components/editor/store/collaboration-state.tsx @@ -9,6 +9,7 @@ import { useBroadcastEvent, useEventListener } from '../../../../liveblocks.conf interface CollaborationStateUpdaterProps { projectId: string | null + loggedIn: boolean dispatch: EditorDispatch } @@ -18,7 +19,7 @@ const controlChangedEvent: ControlChangedRoomEvent = { export const CollaborationStateUpdater = React.memo( (props: React.PropsWithChildren) => { - const { projectId, dispatch, children } = props + const { projectId, dispatch, loggedIn, children } = props const isMyProjectRef = useRefEditorState((store) => store.projectServerState.isMyProject) const currentlyHolderOfTheBatonRef = useRefEditorState( (store) => store.projectServerState.currentlyHolderOfTheBaton, @@ -45,7 +46,7 @@ export const CollaborationStateUpdater = React.memo( // Handle events that appear to have come from the above broadcast call. useEventListener((data) => { if (data.event.type === 'CONTROL_CHANGED') { - if (isMyProjectRef.current === 'yes') { + if (loggedIn && isMyProjectRef.current === 'yes') { void CollaborationEndpoints.claimControlOverProject(projectId) .then((controlResult) => { const newHolderOfTheBaton = controlResult ?? false @@ -67,10 +68,12 @@ export const CollaborationStateUpdater = React.memo( function attemptToSnatchControl(): void { if (projectId != null) { // Only attempt to do any kind of snatching of control if: + // - The user is logged in. // - The project is "mine". // - This instance does not already hold control of the project. // - There isn't already an attempt to snatch control inflight. if ( + loggedIn && isMyProjectRef.current === 'yes' && !currentlyHolderOfTheBatonRef.current && !currentlyAttemptingToSnatch @@ -109,6 +112,7 @@ export const CollaborationStateUpdater = React.memo( isMyProjectRef, currentlyHolderOfTheBatonRef, currentlyAttemptingToSnatch, + loggedIn, ]) return <>{children} }, diff --git a/editor/src/components/editor/store/collaborative-editing.ts b/editor/src/components/editor/store/collaborative-editing.ts index 9c96c153bf62..1011b40ac982 100644 --- a/editor/src/components/editor/store/collaborative-editing.ts +++ b/editor/src/components/editor/store/collaborative-editing.ts @@ -11,6 +11,7 @@ import type { CollabTextFileImports, CollabTextFileTopLevelElements, CollaborativeEditingSupportSession, + UserState, } from './editor-state' import type { ProjectContentsTree } from '../../../components/assets' import { @@ -19,7 +20,8 @@ import { isProjectContentFile, zipContentsTree, } from '../../../components/assets' -import type { EditorAction, EditorDispatch } from '../action-types' +import type { LoginState } from '../action-types' +import { isLoggedIn, type EditorAction, type EditorDispatch } from '../action-types' import { updateTopLevelElementsFromCollaborationUpdate } from '../actions/action-creators' import { assertNever } from '../../../core/shared/utils' import type { @@ -610,20 +612,35 @@ function synchroniseParseSuccessToCollabFile( ) } -export function allowedToEditProject(serverState: ProjectServerState): boolean { +export function allowedToEditProject( + loginState: LoginState, + serverState: ProjectServerState, +): boolean { if (isFeatureEnabled('Baton Passing For Control')) { - return serverState.currentlyHolderOfTheBaton + if (isLoggedIn(loginState)) { + return serverState.currentlyHolderOfTheBaton + } else { + return checkIsMyProject(serverState) + } } else { return checkIsMyProject(serverState) } } export function useAllowedToEditProject(): boolean { - return useEditorState( + const projectServerState = useEditorState( Substores.projectServerState, - (store) => allowedToEditProject(store.projectServerState), - 'useAllowedToEditProject', + (store) => store.projectServerState, + 'useAllowedToEditProject projectServerState', ) + + const loginState = useEditorState( + Substores.userState, + (store) => store.userState.loginState, + 'useAllowedToEditProject loginState', + ) + + return allowedToEditProject(loginState, projectServerState) } export function useIsMyProject(): boolean { diff --git a/editor/src/components/editor/store/dispatch-strategies.tsx b/editor/src/components/editor/store/dispatch-strategies.tsx index 8ce54f61e81f..178f9a7e214c 100644 --- a/editor/src/components/editor/store/dispatch-strategies.tsx +++ b/editor/src/components/editor/store/dispatch-strategies.tsx @@ -658,7 +658,7 @@ export function handleStrategies( let unpatchedEditorState: EditorState let patchedEditorState: EditorState let newStrategyState: StrategyState - if (allowedToEditProject(storedState.projectServerState)) { + if (allowedToEditProject(storedState.userState.loginState, storedState.projectServerState)) { const strategiesResult = handleStrategiesInner( strategies, dispatchedActions, diff --git a/editor/src/components/editor/store/dispatch.tsx b/editor/src/components/editor/store/dispatch.tsx index a465b54e95cc..43516be6e31a 100644 --- a/editor/src/components/editor/store/dispatch.tsx +++ b/editor/src/components/editor/store/dispatch.tsx @@ -110,6 +110,7 @@ function processAction( ): EditorStoreUnpatched { return gatedActions( action, + editorStoreUnpatched.userState.loginState, editorStoreUnpatched.projectServerState, editorStoreUnpatched.unpatchedEditor, editorStoreUnpatched, @@ -167,11 +168,6 @@ function processAction( userState: UPDATE_FNS.SET_CURRENT_THEME(action, working.userState), } } else if (action.action === 'SET_LOGIN_STATE') { - void updateProjectServerStateInStore( - editorStoreUnpatched.unpatchedEditor.id, - editorStoreUnpatched.unpatchedEditor.forkedFromProjectId, - dispatchEvent, - ) return { ...working, userState: UPDATE_FNS.SET_LOGIN_STATE(action, working.userState), @@ -711,7 +707,7 @@ export function editorDispatchClosingOut( } maybeCullElementPathCache( - storedState.unpatchedEditor.projectContents, + finalStoreV1Final.unpatchedEditor.projectContents, anyWorkerUpdates ? 'schedule-now' : 'dont-schedule', ) diff --git a/editor/src/components/editor/store/editor-update.tsx b/editor/src/components/editor/store/editor-update.tsx index 077d11cb88c8..0f8629f319a3 100644 --- a/editor/src/components/editor/store/editor-update.tsx +++ b/editor/src/components/editor/store/editor-update.tsx @@ -9,6 +9,7 @@ import type { EditorAction, EditorDispatch, ExecutePostActionMenuChoice, + LoginState, StartPostActionSession, UpdateProjectServerState, } from '../action-types' @@ -64,6 +65,7 @@ export function runLocalEditorAction( export function gatedActions( action: EditorAction, + loginState: LoginState, serverState: ProjectServerState, editorState: EditorState, defaultValue: T, @@ -88,7 +90,7 @@ export function gatedActions( break } - const canEditProject = allowedToEditProject(serverState) + const canEditProject = allowedToEditProject(loginState, serverState) if (alwaysRun) { // If it should always run. diff --git a/editor/src/components/editor/store/project-server-state.tsx b/editor/src/components/editor/store/project-server-state.tsx index e46cbf893458..4d9ae09be8f1 100644 --- a/editor/src/components/editor/store/project-server-state.tsx +++ b/editor/src/components/editor/store/project-server-state.tsx @@ -4,7 +4,7 @@ import { IS_TEST_ENVIRONMENT } from '../../../common/env-vars' import { fetchProjectMetadata } from '../../../common/server' import type { EditorDispatch } from '../action-types' import { updateProjectServerState } from '../actions/action-creators' -import { checkProjectOwned } from '../persistence/persistence-backend' +import { checkProjectOwned, projectIsStoredLocally } from '../persistence/persistence-backend' import type { ProjectOwnership } from '../persistence/generic/persistence-types' import { isFeatureEnabled } from '../../../utils/feature-switches' import { CollaborationEndpoints } from '../collaborative-endpoints' @@ -76,75 +76,91 @@ function projectListingToProjectMetadataFromServer( } export async function getProjectServerState( + loggedIn: boolean, dispatch: EditorDispatch, projectId: string | null, forkedFromProjectId: string | null, ): Promise { - if (IS_TEST_ENVIRONMENT) { - return { - ...emptyProjectServerState(), - isMyProject: 'yes', - currentlyHolderOfTheBaton: true, + const existsLocally = projectId == null ? true : await projectIsStoredLocally(projectId) + const projectListing = + projectId == null || existsLocally ? null : await fetchProjectMetadata(projectId) + const forkedFromProjectListing = + forkedFromProjectId == null || existsLocally + ? null + : await fetchProjectMetadata(forkedFromProjectId) + + async function getOwnership(): Promise { + if (projectId == null) { + return { isOwner: true, ownerId: null } } - } else { - const projectListing = projectId == null ? null : await fetchProjectMetadata(projectId) - const forkedFromProjectListing = - forkedFromProjectId == null ? null : await fetchProjectMetadata(forkedFromProjectId) - - async function getOwnership(): Promise { - if (projectId == null) { - return { isOwner: true, ownerId: null } - } - const { isOwner, ownerId } = await checkProjectOwned(projectId) - return { isOwner, ownerId } + const { isOwner, ownerId } = await checkProjectOwned(loggedIn, projectId) + return { isOwner, ownerId } + } + const ownership = await getOwnership() + + async function getHolderOfTheBaton(): Promise { + if (!loggedIn) { + return false + } else if (ownership.isOwner) { + return CollaborationEndpoints.claimControlOverProject(projectId) + .then((result) => { + return result ?? ownership.isOwner + }) + .catch(() => { + CollaborationEndpoints.displayControlErrorToast( + dispatch, + 'Error while attempting to claim control over this project.', + ) + return false + }) + } else { + return false } - const ownership = await getOwnership() - - const holderOfTheBaton = ownership.isOwner - ? await CollaborationEndpoints.claimControlOverProject(projectId) - .then((result) => { - return result ?? ownership.isOwner - }) - .catch(() => { - CollaborationEndpoints.displayControlErrorToast( - dispatch, - 'Error while attempting to claim control over this project.', - ) - return false - }) - : false + } - return { - isMyProject: ownership.isOwner ? 'yes' : 'no', - ownerId: ownership.ownerId, - projectData: projectListingToProjectMetadataFromServer(projectListing), - forkedFromProjectData: projectListingToProjectMetadataFromServer(forkedFromProjectListing), - currentlyHolderOfTheBaton: holderOfTheBaton, - } + const holderOfTheBaton = await getHolderOfTheBaton() + + return { + isMyProject: ownership.isOwner ? 'yes' : 'no', + ownerId: ownership.ownerId, + projectData: projectListingToProjectMetadataFromServer(projectListing), + forkedFromProjectData: projectListingToProjectMetadataFromServer(forkedFromProjectListing), + currentlyHolderOfTheBaton: holderOfTheBaton, } } type SuccessOrFailure = 'success' | 'failure' +let updateProjectServerStateInStoreRunCount: number = 0 + +export function getUpdateProjectServerStateInStoreRunCount(): number { + return updateProjectServerStateInStoreRunCount +} + export async function updateProjectServerStateInStore( + loggedIn: boolean, projectId: string | null, forkedFromProjectId: string | null, dispatch: EditorDispatch, ): Promise { - return getProjectServerState(dispatch, projectId, forkedFromProjectId) + return getProjectServerState(loggedIn, dispatch, projectId, forkedFromProjectId) .then((serverState) => { dispatch([updateProjectServerState(serverState)], 'everyone') return 'success' }) - .catch((error) => { + .catch((error) => { console.error('Error while updating server state.', error) return 'failure' }) + .finally(() => { + updateProjectServerStateInStoreRunCount++ + }) } export interface ProjectServerStateUpdaterProps { projectId: string | null forkedFromProjectId: string | null + loggedIn: boolean dispatch: EditorDispatch } @@ -153,12 +169,13 @@ const baseWatcherIntervalTime: number = 10 * 1000 let currentWatcherIntervalMultiplier: number = 1 function restartServerStateWatcher( + loggedIn: boolean, projectId: string | null, forkedFromProjectId: string | null, dispatch: EditorDispatch, ): void { if (isFeatureEnabled('Baton Passing For Control')) { - void updateProjectServerStateInStore(projectId, forkedFromProjectId, dispatch) + void updateProjectServerStateInStore(loggedIn, projectId, forkedFromProjectId, dispatch) // Reset the multiplier if triggered from outside of `restartWatcherInterval`. currentWatcherIntervalMultiplier = 1 @@ -167,40 +184,43 @@ function restartServerStateWatcher( window.clearInterval(serverStateWatcherInstance) } serverStateWatcherInstance = window.setInterval(() => { - void updateProjectServerStateInStore(projectId, forkedFromProjectId, dispatch).then( - (result) => { - // If there's a failure, then double the multiplier and recreate the interval. - if (result === 'failure') { - if (currentWatcherIntervalMultiplier < 10) { - currentWatcherIntervalMultiplier = currentWatcherIntervalMultiplier * 2 - restartWatcherInterval() - } + void updateProjectServerStateInStore( + loggedIn, + projectId, + forkedFromProjectId, + dispatch, + ).then((result) => { + // If there's a failure, then double the multiplier and recreate the interval. + if (result === 'failure') { + if (currentWatcherIntervalMultiplier < 10) { + currentWatcherIntervalMultiplier = currentWatcherIntervalMultiplier * 2 + restartWatcherInterval() } - - // If this call succeeds, reset the multiplier and recreate the interval if the multiplier - // has changed. - if (result === 'success') { - if (currentWatcherIntervalMultiplier !== 1) { - currentWatcherIntervalMultiplier = 1 - restartWatcherInterval() - } + } + + // If this call succeeds, reset the multiplier and recreate the interval if the multiplier + // has changed. + if (result === 'success') { + if (currentWatcherIntervalMultiplier !== 1) { + currentWatcherIntervalMultiplier = 1 + restartWatcherInterval() } - }, - ) + } + }) }, baseWatcherIntervalTime * currentWatcherIntervalMultiplier) } restartWatcherInterval() } else { - void updateProjectServerStateInStore(projectId, forkedFromProjectId, dispatch) + void updateProjectServerStateInStore(loggedIn, projectId, forkedFromProjectId, dispatch) } } export const ProjectServerStateUpdater = React.memo( (props: React.PropsWithChildren) => { - const { projectId, forkedFromProjectId, dispatch, children } = props + const { projectId, forkedFromProjectId, dispatch, loggedIn, children } = props React.useEffect(() => { - restartServerStateWatcher(projectId, forkedFromProjectId, dispatch) - }, [dispatch, projectId, forkedFromProjectId]) + restartServerStateWatcher(loggedIn, projectId, forkedFromProjectId, dispatch) + }, [dispatch, projectId, forkedFromProjectId, loggedIn]) return <>{children} }, ) diff --git a/editor/src/components/navigator/navigator-item/navigator-item.spec.browser2.tsx b/editor/src/components/navigator/navigator-item/navigator-item.spec.browser2.tsx index 655131229fde..dbbae13dbe80 100644 --- a/editor/src/components/navigator/navigator-item/navigator-item.spec.browser2.tsx +++ b/editor/src/components/navigator/navigator-item/navigator-item.spec.browser2.tsx @@ -171,6 +171,8 @@ describe('Navigator item row icons', () => { ) ` + // The navigator appears to be a little buggy: + // https://github.com/concrete-utopia/utopia/issues/4773 it('Should show the correct icons for each type of row', async () => { const editor = await renderTestEditorWithCode(testProjectCode, 'await-first-dom-report') const visibleNavigatorTargets = editor.getEditorState().derived.visibleNavigatorTargets @@ -302,6 +304,8 @@ describe('Navigator item row icons', () => { ) }) + // The navigator appears to be a little buggy: + // https://github.com/concrete-utopia/utopia/issues/4773 it('Should show the correct labels for each type of row', async () => { const editor = await renderTestEditorWithCode(testProjectCode, 'await-first-dom-report') const visibleNavigatorTargets = editor.getEditorState().derived.visibleNavigatorTargets diff --git a/editor/src/core/shared/element-path.spec.browser2.tsx b/editor/src/core/shared/element-path.spec.browser2.tsx index 876699c28c5c..11c80f297fa2 100644 --- a/editor/src/core/shared/element-path.spec.browser2.tsx +++ b/editor/src/core/shared/element-path.spec.browser2.tsx @@ -17,7 +17,11 @@ describe('ElementPath Caching', () => { const realPath = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) const fakePath = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb', 'ccc']) - // Rendering alone will be enough to trigger the timer for culling the cache + // let's make sure the paths are currently still in the cache + expect(EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb'])).toBe(realPath) + expect(EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb', 'ccc'])).toBe(fakePath) + + // Rendering alone will usually be enough to trigger the timer for culling the cache. const renderResult = await renderTestEditorWithCode( makeTestProjectCodeWithSnippet(`
@@ -26,11 +30,6 @@ describe('ElementPath Caching', () => { `), 'await-first-dom-report', ) - - // let's make sure the paths are currently still in the cache - expect(EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb'])).toBe(realPath) - expect(EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb', 'ccc'])).toBe(fakePath) - setLastProjectContentsForTesting(renderResult.getEditorState().editor.projectContents) cullElementPathCache() diff --git a/editor/src/core/shared/multiplayer-hooks.tsx b/editor/src/core/shared/multiplayer-hooks.tsx index cc9d17f7b1bd..b68c3cdb1fab 100644 --- a/editor/src/core/shared/multiplayer-hooks.tsx +++ b/editor/src/core/shared/multiplayer-hooks.tsx @@ -56,6 +56,14 @@ export function useMyUserId(): string | null { return myUserId } +export function useIsLoggedIn(): boolean { + return useEditorState( + Substores.restOfStore, + (store) => isLoggedIn(store.userState.loginState), + 'useIsLoggedIn', + ) +} + export function useIsBeingFollowed() { const mode = useEditorState( Substores.restOfEditor, diff --git a/editor/test/karma-custom-reporter/full-console-messages.js b/editor/test/karma-custom-reporter/full-console-messages.js index d4b091122329..f83a36eb95c5 100644 --- a/editor/test/karma-custom-reporter/full-console-messages.js +++ b/editor/test/karma-custom-reporter/full-console-messages.js @@ -15,7 +15,9 @@ var FullConsoleMessages = function (baseReporterDecorator, formatError, config) function printCleanConsoleMessages(consoleMessages) { var messagesFiltered = [] consoleMessages.forEach((message) => { - self.write(`${message.type.toUpperCase()} ${message.log}\n`) + self.write( + `${message.timestamp.toISOString()} ${message.type.toUpperCase()} ${message.log}\n`, + ) self.write(`this console message is from: \n ${message.specName}\n`) self.write(`\n`) }) @@ -23,6 +25,7 @@ var FullConsoleMessages = function (baseReporterDecorator, formatError, config) self.onBrowserLog = function (browser, log, type) { consoleMessagesForSpec.push({ + timestamp: new Date(), type: type, log: log, }) diff --git a/website-next/components/common/server.ts b/website-next/components/common/server.ts index 1617847535f3..91e211b8af00 100644 --- a/website-next/components/common/server.ts +++ b/website-next/components/common/server.ts @@ -1,7 +1,14 @@ import localforage from 'localforage' -import { UTOPIA_BACKEND, THUMBNAIL_ENDPOINT, ASSET_ENDPOINT, BASE_URL } from './env-vars' +import { + UTOPIA_BACKEND, + THUMBNAIL_ENDPOINT, + ASSET_ENDPOINT, + BASE_URL, + IS_TEST_ENVIRONMENT, +} from './env-vars' import type { ProjectListing } from './persistence' import type { LoginState } from './user' +import { loggedInUser } from './user' import { cookiesOrLocalForageUnavailable, isLoggedIn, @@ -77,12 +84,16 @@ export function userConfigURL(): string { let CachedLoginStatePromise: Promise | null = null export async function getLoginState(useCache: 'cache' | 'no-cache'): Promise { - if (useCache === 'cache' && CachedLoginStatePromise != null) { - return CachedLoginStatePromise + if (IS_TEST_ENVIRONMENT) { + return Promise.resolve(loggedInUser({ userId: '1' })) } else { - const promise = createGetLoginStatePromise(CachedLoginStatePromise) - CachedLoginStatePromise = promise - return promise + if (useCache === 'cache' && CachedLoginStatePromise != null) { + return CachedLoginStatePromise + } else { + const promise = createGetLoginStatePromise(CachedLoginStatePromise) + CachedLoginStatePromise = promise + return promise + } } } @@ -153,23 +164,30 @@ function getLoginStateFromResponse( } export async function checkProjectOwnership(projectId: string): Promise { - const url = `${projectURL(projectId)}/owner` - const response = await fetch(url, { - method: 'GET', - credentials: 'include', - headers: HEADERS, - mode: MODE, - }) - if (response.ok) { - return response.json() - } else if (response.status === 404) { - return 'unowned' - } else { - // FIXME Client should show an error if server requests fail - console.error(`server responded with ${response.status} ${response.statusText}`) + if (IS_TEST_ENVIRONMENT) { return { - isOwner: false, - ownerId: null, + isOwner: true, + ownerId: '1', + } + } else { + const url = `${projectURL(projectId)}/owner` + const response = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: HEADERS, + mode: MODE, + }) + if (response.ok) { + return response.json() + } else if (response.status === 404) { + return 'unowned' + } else { + // FIXME Client should show an error if server requests fail + console.error(`server responded with ${response.status} ${response.statusText}`) + return { + isOwner: false, + ownerId: null, + } } } } @@ -228,19 +246,23 @@ export async function fetchShowcaseProjects(): Promise> { } export async function fetchProjectMetadata(projectId: string): Promise { - // GETs the metadata for a given project ID - const url = urljoin(UTOPIA_BACKEND, 'project', projectId, 'metadata') - const response = await fetch(url, { - method: 'GET', - credentials: 'include', - headers: HEADERS, - mode: MODE, - }) - if (response.ok) { - const responseBody: ServerProjectListing = await response.json() - return serverProjectListingToProjectListing(responseBody) - } else { + if (IS_TEST_ENVIRONMENT) { return null + } else { + // GETs the metadata for a given project ID + const url = urljoin(UTOPIA_BACKEND, 'project', projectId, 'metadata') + const response = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: HEADERS, + mode: MODE, + }) + if (response.ok) { + const responseBody: ServerProjectListing = await response.json() + return serverProjectListingToProjectListing(responseBody) + } else { + return null + } } }