From d5661e564e645a0f9b02043d465626b96790fca1 Mon Sep 17 00:00:00 2001 From: Sean Parsons <217400+seanparsons@users.noreply.github.com> Date: Tue, 2 Jan 2024 15:55:16 +0000 Subject: [PATCH 1/4] feature(editor) Baton Passing Basic Edition (#4665) - Added `Baton Passing For Control` feature switch. - Add a call to release control of the projects to the existing `beforeunload` event listener. - Added `claimControlOverProject` and `releaseControl` calls to the server powered by `callCollaborationEndpoint`. - Added `allowedToEditProject` and associated hook function `useAllowedToEditProject`, to replace some existing functions like `useIsViewer`. - Added `currentlyHolderOfTheBaton` to `ProjectServerState`. - Updated `getProjectServerState` to call `claimControlOverProject`. - `ProjectServerStateUpdater` now defers to a new function `restartServerStateWatcher`, which updates on an interval if the appropriate feature flag is enabled. - Added `project_collaboration` table to the database. - Implemented `collaborationEndpoint` in the server to dispatch the various cases of collaboration request. - Extracted out some functions into `Utopia.Web.Endpoints.Common` module. - Added `ClaimCollaborationOwnership` and `ClearCollaboratorOwnership` cases to service types in the backend. --- .../components/canvas/design-panel-root.tsx | 8 +- .../src/components/editor/canvas-toolbar.tsx | 12 +- .../components/editor/editor-component.tsx | 11 +- .../components/editor/global-shortcuts.tsx | 94 +++++++------ .../editor/persistence/persistence.ts | 7 +- .../components/editor/project-owner-hooks.ts | 51 +++---- .../editor/store/collaborative-editing.ts | 116 +++++++++++++++- .../editor/store/dispatch-strategies.tsx | 12 +- .../components/editor/store/editor-update.tsx | 11 +- .../store/project-server-state-hooks.tsx | 11 -- .../editor/store/project-server-state.tsx | 52 ++++--- .../store/store-deep-equality-instances.ts | 4 +- editor/src/utils/feature-switches.ts | 3 + server/migrations/004.sql | 9 ++ server/src/Utopia/Web/Database.hs | 130 ++++++++++++++---- server/src/Utopia/Web/Database/Migrations.hs | 1 + server/src/Utopia/Web/Database/Types.hs | 20 ++- server/src/Utopia/Web/Endpoints.hs | 47 +++---- .../src/Utopia/Web/Endpoints/Collaboration.hs | 72 ++++++++++ server/src/Utopia/Web/Endpoints/Common.hs | 72 ++++++++++ .../src/Utopia/Web/Executors/Development.hs | 12 ++ server/src/Utopia/Web/Executors/Production.hs | 12 ++ server/src/Utopia/Web/ServiceTypes.hs | 2 + server/src/Utopia/Web/Types.hs | 6 +- server/src/Utopia/Web/Types/Collaboration.hs | 85 ++++++++++++ server/utopia-web.cabal | 6 + 26 files changed, 669 insertions(+), 197 deletions(-) delete mode 100644 editor/src/components/editor/store/project-server-state-hooks.tsx create mode 100644 server/migrations/004.sql create mode 100644 server/src/Utopia/Web/Endpoints/Collaboration.hs create mode 100644 server/src/Utopia/Web/Endpoints/Common.hs create mode 100644 server/src/Utopia/Web/Types/Collaboration.hs diff --git a/editor/src/components/canvas/design-panel-root.tsx b/editor/src/components/canvas/design-panel-root.tsx index 9dd2fba597ca..f84a785a1b5a 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') { @@ -169,7 +169,7 @@ export const RightPane = React.memo((props) => { onClickTab(RightMenuTab.Settings) }, [onClickTab]) - const isViewer = useIsViewer() + const allowedToEdit = useAllowedToEditProject() if (!isRightMenuExpanded) { return null @@ -195,8 +195,8 @@ export const RightPane = React.memo((props) => { selected={selectedTab === RightMenuTab.Inspector} onClick={onClickInspectorTab} /> - {unless( - isViewer, + {when( + allowedToEdit, <> { ? 'Not connected to room' : 'Comment Mode' - 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', @@ -673,7 +673,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/utils/feature-switches.ts b/editor/src/utils/feature-switches.ts index be721a21009b..3edc933d0394 100644 --- a/editor/src/utils/feature-switches.ts +++ b/editor/src/utils/feature-switches.ts @@ -16,6 +16,7 @@ export type FeatureName = | 'Debug - Print UIDs' | 'Steganography' | 'Collaboration' + | 'Baton Passing For Control' export const AllFeatureNames: FeatureName[] = [ // 'Dragging Reparents By Default', // Removing this option so that we can experiment on this later @@ -32,6 +33,7 @@ export const AllFeatureNames: FeatureName[] = [ 'Debug - Print UIDs', 'Steganography', 'Collaboration', + 'Baton Passing For Control', ] let FeatureSwitches: { [feature in FeatureName]: boolean } = { @@ -48,6 +50,7 @@ let FeatureSwitches: { [feature in FeatureName]: boolean } = { 'Debug - Print UIDs': false, Steganography: false, Collaboration: false, + 'Baton Passing For Control': false, } let FeatureSwitchLoaded: { [feature in FeatureName]?: boolean } = {} diff --git a/server/migrations/004.sql b/server/migrations/004.sql new file mode 100644 index 000000000000..b4f8885f2e14 --- /dev/null +++ b/server/migrations/004.sql @@ -0,0 +1,9 @@ +CREATE TABLE "public"."project_collaboration" ( + "project_id" character varying NOT NULL, + "collaboration_editor" character varying NOT NULL, + "last_seen_timeout" timestamp with time zone NOT NULL +); + +CREATE INDEX "project_collaboration_project_id_index" ON "project_collaboration" ("project_id"); + +CREATE INDEX "project_collaboration_collaboration_editor_index" ON "project_collaboration" ("collaboration_editor"); diff --git a/server/src/Utopia/Web/Database.hs b/server/src/Utopia/Web/Database.hs index 53400e7d02f3..bc21883a48a4 100644 --- a/server/src/Utopia/Web/Database.hs +++ b/server/src/Utopia/Web/Database.hs @@ -14,50 +14,54 @@ module Utopia.Web.Database where -import Control.Lens hiding ((.>)) +import Control.Lens hiding ((.>)) import Control.Monad.Catch import Control.Monad.Fail import Data.Aeson import Data.Aeson.Lens -import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy as BL import Data.Generics.Product import Data.Generics.Sum import Data.Pool import Data.Profunctor.Product.Default import Data.String -import qualified Data.Text as T +import qualified Data.Text as T import Data.Time -import Data.UUID hiding (null) +import Data.UUID hiding (null) import Data.UUID.V4 import Database.PostgreSQL.Simple +import Database.PostgreSQL.Simple.Transaction import Opaleye -import Protolude hiding (get) +import Protolude hiding (get) import System.Environment import System.Posix.User import Utopia.ClientModel import Utopia.Web.Database.Types -import Utopia.Web.Metrics hiding (count) +import Utopia.Web.Metrics hiding (count) data DatabaseMetrics = DatabaseMetrics - { _generateUniqueIDMetrics :: InvocationMetric - , _insertProjectMetrics :: InvocationMetric - , _saveProjectMetrics :: InvocationMetric - , _createProjectMetrics :: InvocationMetric - , _deleteProjectMetrics :: InvocationMetric - , _loadProjectMetrics :: InvocationMetric - , _getProjectsForUserMetrics :: InvocationMetric - , _getProjectOwnerMetrics :: InvocationMetric - , _getProjectOwnerDetailsMetrics :: InvocationMetric - , _checkIfProjectOwnerMetrics :: InvocationMetric - , _getShowcaseProjectsMetrics :: InvocationMetric - , _setShowcaseProjectsMetrics :: InvocationMetric - , _updateUserDetailsMetrics :: InvocationMetric - , _getUserDetailsMetrics :: InvocationMetric - , _getUserConfigurationMetrics :: InvocationMetric - , _saveUserConfigurationMetrics :: InvocationMetric - , _checkIfProjectIDReservedMetrics :: InvocationMetric - , _updateGithubAuthenticationDetailsMetrics ::InvocationMetric - , _getGithubAuthenticationDetailsMetrics :: InvocationMetric + { _generateUniqueIDMetrics :: InvocationMetric + , _insertProjectMetrics :: InvocationMetric + , _saveProjectMetrics :: InvocationMetric + , _createProjectMetrics :: InvocationMetric + , _deleteProjectMetrics :: InvocationMetric + , _loadProjectMetrics :: InvocationMetric + , _getProjectsForUserMetrics :: InvocationMetric + , _getProjectOwnerMetrics :: InvocationMetric + , _getProjectOwnerDetailsMetrics :: InvocationMetric + , _checkIfProjectOwnerMetrics :: InvocationMetric + , _getShowcaseProjectsMetrics :: InvocationMetric + , _setShowcaseProjectsMetrics :: InvocationMetric + , _updateUserDetailsMetrics :: InvocationMetric + , _getUserDetailsMetrics :: InvocationMetric + , _getUserConfigurationMetrics :: InvocationMetric + , _saveUserConfigurationMetrics :: InvocationMetric + , _checkIfProjectIDReservedMetrics :: InvocationMetric + , _updateGithubAuthenticationDetailsMetrics :: InvocationMetric + , _getGithubAuthenticationDetailsMetrics :: InvocationMetric + , _maybeClaimCollaborationControlMetrics :: InvocationMetric + , _deleteCollaborationControlByCollaboratorMetrics :: InvocationMetric + , _deleteCollaborationControlByProjectMetrics :: InvocationMetric } createDatabaseMetrics :: Store -> IO DatabaseMetrics @@ -80,7 +84,10 @@ createDatabaseMetrics store = DatabaseMetrics <*> createInvocationMetric "utopia.database.saveuserconfiguration" store <*> createInvocationMetric "utopia.database.checkifprojectidreserved" store <*> createInvocationMetric "utopia.database.updategithubauthenticationdetails" store - <*> createInvocationMetric "utopia.database.lookupgithubauthenticationdetails" store + <*> createInvocationMetric "utopia.database.getgithubauthenticationdetails" store + <*> createInvocationMetric "utopia.database.maybeclaimcollaborationownership" store + <*> createInvocationMetric "utopia.database.deletecollaborationownershipbycollaborator" store + <*> createInvocationMetric "utopia.database.deletecollaborationownershipbyproject" store data UserIDIncorrectException = UserIDIncorrectException deriving (Eq, Show) @@ -437,3 +444,74 @@ lookupGithubAuthenticationDetails metrics pool userId = invokeAndMeasure (_getGi where_ $ rowUserId .== toFields userId pure githubAuthenticationDetailsRow pure $ fmap githubAuthenticationDetailsFromRow $ listToMaybe githubAuthenticationDetails + +insertCollaborationControl :: Connection -> Text -> Text -> UTCTime -> IO () +insertCollaborationControl connection projectId collaborationEditor currentTime = do + let newLastSeenTimeout = addUTCTime collaborationLastSeenTimeoutWindow currentTime + void $ runInsert_ connection $ Insert + { iTable = projectCollaborationTable + , iRows = [toFields (projectId, collaborationEditor, newLastSeenTimeout)] + , iReturning = rCount + , iOnConflict = Nothing + } + +collaborationLastSeenTimeoutWindow :: NominalDiffTime +collaborationLastSeenTimeoutWindow = secondsToNominalDiffTime 20 + +updateCollaborationLastSeenTimeout :: Connection -> Text -> Text -> UTCTime -> IO () +updateCollaborationLastSeenTimeout connection projectId newCollaborationEditor newLastSeenTimeout = do + void $ runUpdate_ connection $ Update + { uTable = projectCollaborationTable + , uUpdateWith = updateEasy (\(rowProjectId, _, _) -> (rowProjectId, toFields newCollaborationEditor, toFields newLastSeenTimeout)) + , uWhere = (\(rowProjectId, _, _) -> rowProjectId .=== toFields projectId) + , uReturning = rCount + } + +maybeClaimExistingCollaborationControl :: Connection -> Text -> Text -> Text -> UTCTime -> UTCTime -> IO Bool +maybeClaimExistingCollaborationControl connection projectId currentCollaborationEditor newCollaborationEditor currentLastSeenTimeout currentTime + | currentCollaborationEditor == newCollaborationEditor = updateLastSeen + | currentLastSeenTimeout < currentTime = updateLastSeen + | otherwise = pure False + where + newTimeout = addUTCTime collaborationLastSeenTimeoutWindow currentTime + updateLastSeen = updateCollaborationLastSeenTimeout connection projectId newCollaborationEditor newTimeout >> pure True + +maybeClaimCollaborationControl :: DatabaseMetrics -> DBPool -> Text -> Text -> IO Bool +maybeClaimCollaborationControl metrics pool projectId collaborationEditor = do + currentTime <- getCurrentTime + invokeAndMeasure (_maybeClaimCollaborationControlMetrics metrics) $ usePool pool $ \connection -> do + withTransaction connection $ do + -- Find any existing entries (should only be one). + collaborationEditorIdsWithLastSeen <- runSelect connection $ do + (rowProjectId, rowCollaborationEditor, rowLastSeenTimeout) <- projectCollaborationSelect + where_ $ rowProjectId .== toFields projectId + pure (rowCollaborationEditor, rowLastSeenTimeout) + -- Get the first if there is one. + let maybeCurrentCollaborationEditorAndLastSeen = listToMaybe collaborationEditorIdsWithLastSeen + -- Create the expression that inserts an entry and returns true to indicate this was successfully claimed. + let insertCurrent = insertCollaborationControl connection projectId collaborationEditor currentTime >> pure True + -- Handle the current state. + case maybeCurrentCollaborationEditorAndLastSeen of + -- There's no current entry in the table, so add one. + Nothing -> insertCurrent + -- If the current entry is the same as the one we're trying to insert return true, otherwise + -- return false but in either case make no changes to the database. + Just (currentCollaborationEditor, currentLastSeenTimeout) -> + maybeClaimExistingCollaborationControl connection projectId currentCollaborationEditor collaborationEditor currentLastSeenTimeout currentTime + +deleteCollaborationControlByCollaborator :: DatabaseMetrics -> DBPool -> Text -> IO () +deleteCollaborationControlByCollaborator metrics pool collaborationEditor = invokeAndMeasure (_deleteCollaborationControlByCollaboratorMetrics metrics) $ usePool pool $ \connection -> do + void $ runDelete_ connection $ Delete + { dTable = projectCollaborationTable + , dWhere = (\(_, rowCollaborationEditor, _) -> rowCollaborationEditor .== toFields collaborationEditor) + , dReturning = rCount + } + +deleteCollaborationControlByProject :: DatabaseMetrics -> DBPool -> Text -> IO () +deleteCollaborationControlByProject metrics pool projectId = invokeAndMeasure (_deleteCollaborationControlByProjectMetrics metrics) $ usePool pool $ \connection -> do + void $ runDelete_ connection $ Delete + { dTable = projectCollaborationTable + , dWhere = (\(rowProjectId, _, _) -> rowProjectId .== toFields projectId) + , dReturning = rCount + } + diff --git a/server/src/Utopia/Web/Database/Migrations.hs b/server/src/Utopia/Web/Database/Migrations.hs index c795fd37d114..29d80a7983e5 100644 --- a/server/src/Utopia/Web/Database/Migrations.hs +++ b/server/src/Utopia/Web/Database/Migrations.hs @@ -25,6 +25,7 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection -> let mainMigrationCommands = [ MigrationFile "001.sql" "./migrations/001.sql" , MigrationFile "002.sql" "./migrations/002.sql" , MigrationFile "003.sql" "./migrations/003.sql" + , MigrationFile "004.sql" "./migrations/004.sql" ] let initialMigrationCommand = if includeInitial then [MigrationFile "initial.sql" "./migrations/initial.sql"] diff --git a/server/src/Utopia/Web/Database/Types.hs b/server/src/Utopia/Web/Database/Types.hs index 9124ef426a02..0eacf8478f0c 100644 --- a/server/src/Utopia/Web/Database/Types.hs +++ b/server/src/Utopia/Web/Database/Types.hs @@ -120,7 +120,6 @@ data DecodedUserConfiguration = DecodedUserConfiguration type DBPool = Pool Connection - type GithubAuthenticationFields = (Field SqlText, Field SqlText, FieldNullable SqlText, FieldNullable SqlTimestamptz) githubAuthenticationTable :: Table GithubAuthenticationFields GithubAuthenticationFields @@ -141,3 +140,22 @@ data GithubAuthenticationDetails = GithubAuthenticationDetails , refreshToken :: Maybe Text , expiresAt :: Maybe UTCTime } deriving (Eq, Show, Generic) + +type ProjectCollaborationFields = (Field SqlText, Field SqlText, Field SqlTimestamptz) + +projectCollaborationTable :: Table ProjectCollaborationFields ProjectCollaborationFields +projectCollaborationTable = table "project_collaboration" (p3 + ( tableField "project_id" + , tableField "collaboration_editor" + , tableField "last_seen_timeout" + ) + ) + +projectCollaborationSelect :: Select ProjectCollaborationFields +projectCollaborationSelect = selectTable projectCollaborationTable + +data ProjectCollaborationDetails = ProjectCollaborationDetails + { projectId :: Text + , collaborationEditor :: Text + , lastSeenTimeout :: UTCTime + } deriving (Eq, Show, Generic) diff --git a/server/src/Utopia/Web/Endpoints.hs b/server/src/Utopia/Web/Endpoints.hs index 586d40b8a4cb..7e3f6a7ee3fe 100644 --- a/server/src/Utopia/Web/Endpoints.hs +++ b/server/src/Utopia/Web/Endpoints.hs @@ -14,17 +14,17 @@ -} module Utopia.Web.Endpoints where -import Control.Arrow ((&&&)) +import Control.Arrow ((&&&)) import Control.Lens import Control.Monad.Trans.Maybe import Data.Aeson import Data.Aeson.Lens -import qualified Data.ByteString.Lazy as BL -import Data.CaseInsensitive hiding (traverse) +import qualified Data.ByteString.Lazy as BL +import Data.CaseInsensitive hiding (traverse) import Data.Generics.Product import Data.Generics.Sum -import qualified Data.HashMap.Strict as M -import qualified Data.Text as T +import qualified Data.HashMap.Strict as M +import qualified Data.Text as T import Data.Text.Encoding import Data.Time import Network.HTTP.Types.Header @@ -32,24 +32,27 @@ import Network.HTTP.Types.Status import Network.OAuth.OAuth2 import Network.Wai import Network.Wai.Middleware.Gzip -import Prelude (String) +import Prelude (String) import Protolude -import Servant hiding - (serveDirectoryFileServer, - serveDirectoryWith, uriPath) -import Servant.Conduit () +import Servant hiding + (serveDirectoryFileServer, + serveDirectoryWith, + uriPath) +import Servant.Conduit () import Servant.RawM.Server -import qualified Text.Blaze.Html5 as H -import Text.Blaze.Html5 ((!)) -import qualified Text.Blaze.Html5.Attributes as HA +import qualified Text.Blaze.Html5 as H +import Text.Blaze.Html5 ((!)) +import qualified Text.Blaze.Html5.Attributes as HA import Text.HTML.TagSoup -import Text.URI hiding (unRText, uriPath) +import Text.URI hiding (unRText, uriPath) import Text.URI.Lens import Utopia.ClientModel import Utopia.Web.Assets -import Utopia.Web.Database (projectContentTreeFromDecodedProject) -import qualified Utopia.Web.Database.Types as DB +import Utopia.Web.Database (projectContentTreeFromDecodedProject) +import qualified Utopia.Web.Database.Types as DB import Utopia.Web.Database.Types +import Utopia.Web.Endpoints.Collaboration +import Utopia.Web.Endpoints.Common import Utopia.Web.Executors.Common import Utopia.Web.Github.Types import Utopia.Web.Packager.NPM @@ -80,17 +83,6 @@ validateSaveRequest saveProjectRequest = -- Parsed content, need to validate the contents tree is not empty. Just (Right projectContents) -> not $ M.null projectContents -checkForUser :: Maybe Text -> (Maybe SessionUser -> ServerMonad a) -> ServerMonad a -checkForUser (Just sessionCookie) action = do - maybeSessionUser <- validateAuth sessionCookie -- ServerMonad (Maybe SessionUser) - action maybeSessionUser -checkForUser _ action = action Nothing - -requireUser :: Maybe Text -> (SessionUser -> ServerMonad a) -> ServerMonad a -requireUser cookie action = do - let checkAction maybeSessionUser = maybe notAuthenticated action maybeSessionUser -- Maybe SessionUser -> ServerMonad a - checkForUser cookie checkAction - renderPageContents :: H.Html -> H.Html renderPageContents pageContents = H.docTypeHtml $ do H.head $ @@ -748,6 +740,7 @@ protected authCookie = logoutPage authCookie :<|> saveGithubAssetEndpoint authCookie :<|> getGithubUserEndpoint authCookie :<|> liveblocksAuthenticationEndpoint authCookie + :<|> collaborationEndpoint authCookie unprotected :: ServerT Unprotected ServerMonad unprotected = authenticate diff --git a/server/src/Utopia/Web/Endpoints/Collaboration.hs b/server/src/Utopia/Web/Endpoints/Collaboration.hs new file mode 100644 index 000000000000..7742e980acaf --- /dev/null +++ b/server/src/Utopia/Web/Endpoints/Collaboration.hs @@ -0,0 +1,72 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeOperators #-} + +{-| + All the endpoints defined in "Utopia.Web.Types.Collaboration" are implemented here. +-} +module Utopia.Web.Endpoints.Collaboration where + +import Control.Arrow ((&&&)) +import Control.Lens +import Control.Monad.Trans.Maybe +import Data.Aeson +import Data.Aeson.Lens +import qualified Data.ByteString.Lazy as BL +import Data.CaseInsensitive hiding (traverse) +import Data.Generics.Product +import Data.Generics.Sum +import qualified Data.HashMap.Strict as M +import qualified Data.Text as T +import Data.Text.Encoding +import Data.Time +import Network.HTTP.Types.Header +import Network.HTTP.Types.Status +import Network.OAuth.OAuth2 +import Network.Wai +import Network.Wai.Middleware.Gzip +import Network.WebSockets +import Prelude (String) +import Protolude +import Servant hiding + (serveDirectoryFileServer, + serveDirectoryWith, uriPath) +import Servant.API.WebSocket +import Servant.Conduit () +import Text.URI hiding (unRText, uriPath) +import Text.URI.Lens +import Utopia.ClientModel +import Utopia.Web.Assets +import Utopia.Web.Database (projectContentTreeFromDecodedProject) +import qualified Utopia.Web.Database.Types as DB +import Utopia.Web.Database.Types +import Utopia.Web.Endpoints.Common +import Utopia.Web.Executors.Common +import Utopia.Web.Github.Types +import Utopia.Web.Packager.NPM +import Utopia.Web.Proxy +import Utopia.Web.Servant +import Utopia.Web.ServiceTypes +import Utopia.Web.Types.Collaboration +import Utopia.Web.Utils.Files +import WaiAppStatic.Storage.Filesystem +import WaiAppStatic.Types + +collaborationEndpoint:: Maybe Text -> CollaborationRequest -> ServerMonad CollaborationResponse +collaborationEndpoint cookie collaborationRequest = do + requireUser cookie $ \sessionUser -> do + let idOfUser = view (field @"_id") sessionUser + case collaborationRequest of + (ClaimProjectControlRequest ClaimProjectControl{..}) -> do + successfullyClaimed <- claimCollaborationControl idOfUser projectID collaborationEditor + pure $ ClaimProjectControlResultResponse $ ClaimProjectControlResult{..} + (ClearAllOfCollaboratorsControlRequest ClearAllOfCollaboratorsControl{..}) -> do + _ <- clearCollaboratorOwnership collaborationEditor + pure $ ClearAllOfCollaboratorsControlResponse ClearAllOfCollaboratorsControlResult diff --git a/server/src/Utopia/Web/Endpoints/Common.hs b/server/src/Utopia/Web/Endpoints/Common.hs new file mode 100644 index 000000000000..0f2219e25f87 --- /dev/null +++ b/server/src/Utopia/Web/Endpoints/Common.hs @@ -0,0 +1,72 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeOperators #-} + +module Utopia.Web.Endpoints.Common where + +import Control.Arrow ((&&&)) +import Control.Lens +import Control.Monad.Trans.Maybe +import Data.Aeson +import Data.Aeson.Lens +import qualified Data.ByteString.Lazy as BL +import Data.CaseInsensitive hiding (traverse) +import Data.Generics.Product +import Data.Generics.Sum +import qualified Data.HashMap.Strict as M +import qualified Data.Text as T +import Data.Text.Encoding +import Data.Time +import Network.HTTP.Types.Header +import Network.HTTP.Types.Status +import Network.OAuth.OAuth2 +import Network.Wai +import Network.Wai.Middleware.Gzip +import Prelude (String) +import Protolude +import Servant hiding + (serveDirectoryFileServer, + serveDirectoryWith, uriPath) +import Servant.Conduit () +import Servant.RawM.Server +import qualified Text.Blaze.Html5 as H +import Text.Blaze.Html5 ((!)) +import qualified Text.Blaze.Html5.Attributes as HA +import Text.HTML.TagSoup +import Text.URI hiding (unRText, uriPath) +import Text.URI.Lens +import Utopia.ClientModel +import Utopia.Web.Assets +import Utopia.Web.Database (projectContentTreeFromDecodedProject) +import qualified Utopia.Web.Database.Types as DB +import Utopia.Web.Database.Types +import Utopia.Web.Executors.Common +import Utopia.Web.Github.Types +import Utopia.Web.Packager.NPM +import Utopia.Web.Proxy +import Utopia.Web.Servant +import Utopia.Web.ServiceTypes +import Utopia.Web.Types +import Utopia.Web.Utils.Files +import WaiAppStatic.Storage.Filesystem +import WaiAppStatic.Types + + +checkForUser :: Maybe Text -> (Maybe SessionUser -> ServerMonad a) -> ServerMonad a +checkForUser (Just sessionCookie) action = do + maybeSessionUser <- validateAuth sessionCookie -- ServerMonad (Maybe SessionUser) + action maybeSessionUser +checkForUser _ action = action Nothing + +requireUser :: Maybe Text -> (SessionUser -> ServerMonad a) -> ServerMonad a +requireUser cookie action = do + let checkAction maybeSessionUser = maybe notAuthenticated action maybeSessionUser -- Maybe SessionUser -> ServerMonad a + checkForUser cookie checkAction + diff --git a/server/src/Utopia/Web/Executors/Development.hs b/server/src/Utopia/Web/Executors/Development.hs index cbd87bf0cd2c..7ab40b84c325 100644 --- a/server/src/Utopia/Web/Executors/Development.hs +++ b/server/src/Utopia/Web/Executors/Development.hs @@ -435,6 +435,18 @@ innerServerExecutor (AuthLiveblocksUser user roomID action) = do innerServerExecutor (IsLiveblocksEnabled action) = do possibleLiveblocksResources <- fmap _liveblocksResources ask pure $ action $ isJust possibleLiveblocksResources +innerServerExecutor (ClaimCollaborationControl user projectID collaborationEditor action) = do + metrics <- fmap _databaseMetrics ask + pool <- fmap _projectPool ask + projectOwnershipResult <- liftIO $ DB.checkIfProjectOwner metrics pool user projectID + unless projectOwnershipResult $ throwError err400 + ownershipResult <- liftIO $ DB.maybeClaimCollaborationControl metrics pool projectID collaborationEditor + pure $ action ownershipResult +innerServerExecutor (ClearCollaboratorOwnership collaborationEditor action) = do + metrics <- fmap _databaseMetrics ask + pool <- fmap _projectPool ask + liftIO $ DB.deleteCollaborationControlByCollaborator metrics pool collaborationEditor + pure action {-| Invokes a service call using the supplied resources. diff --git a/server/src/Utopia/Web/Executors/Production.hs b/server/src/Utopia/Web/Executors/Production.hs index aee3761d0b05..9b66ecf4f39d 100644 --- a/server/src/Utopia/Web/Executors/Production.hs +++ b/server/src/Utopia/Web/Executors/Production.hs @@ -340,6 +340,18 @@ innerServerExecutor (AuthLiveblocksUser user roomID action) = do innerServerExecutor (IsLiveblocksEnabled action) = do possibleLiveblocksResources <- fmap _liveblocksResources ask pure $ action $ isJust possibleLiveblocksResources +innerServerExecutor (ClaimCollaborationControl user projectID collaborationEditor action) = do + metrics <- fmap _databaseMetrics ask + pool <- fmap _projectPool ask + projectOwnershipResult <- liftIO $ DB.checkIfProjectOwner metrics pool user projectID + unless projectOwnershipResult $ throwError err400 + ownershipResult <- liftIO $ DB.maybeClaimCollaborationControl metrics pool projectID collaborationEditor + pure $ action ownershipResult +innerServerExecutor (ClearCollaboratorOwnership collaborationEditor action) = do + metrics <- fmap _databaseMetrics ask + pool <- fmap _projectPool ask + liftIO $ DB.deleteCollaborationControlByCollaborator metrics pool collaborationEditor + pure action readEditorContentFromDisk :: Maybe BranchDownloads -> Maybe Text -> Text -> IO Text readEditorContentFromDisk (Just downloads) (Just branchName) fileName = do diff --git a/server/src/Utopia/Web/ServiceTypes.hs b/server/src/Utopia/Web/ServiceTypes.hs index 383b472b12a3..2f6e1a3df780 100644 --- a/server/src/Utopia/Web/ServiceTypes.hs +++ b/server/src/Utopia/Web/ServiceTypes.hs @@ -144,6 +144,8 @@ data ServiceCallsF a = NotFound | GetGithubUserDetails Text (GetGithubUserResponse -> a) | AuthLiveblocksUser Text Text (Text -> a) | IsLiveblocksEnabled (Bool -> a) + | ClaimCollaborationControl Text Text Text (Bool -> a) + | ClearCollaboratorOwnership Text a deriving Functor {- diff --git a/server/src/Utopia/Web/Types.hs b/server/src/Utopia/Web/Types.hs index 46acecab1e03..f7bb519bb559 100644 --- a/server/src/Utopia/Web/Types.hs +++ b/server/src/Utopia/Web/Types.hs @@ -13,19 +13,20 @@ module Utopia.Web.Types where import Conduit import Data.Aeson import Data.Aeson.TH -import qualified Data.ByteString.Lazy as BL +import qualified Data.ByteString.Lazy as BL import Data.Time import Network.OAuth.OAuth2 import Protolude import Servant import Servant.HTML.Blaze import Servant.RawM.Server -import qualified Text.Blaze.Html5 as H +import qualified Text.Blaze.Html5 as H import Utopia.ClientModel import Utopia.Web.Github.Types import Utopia.Web.JSON import Utopia.Web.Servant import Utopia.Web.ServiceTypes +import Utopia.Web.Types.Collaboration {- 'deriveJSON' as used here creates 'Data.Aeson.FromJSON' and 'Data.Aeson.ToJSON' instances @@ -211,6 +212,7 @@ type Protected = LogoutAPI :<|> GithubSaveAssetAPI :<|> GithubUserAPI :<|> LiveblocksAuthenticationAPI + :<|> CollaborationSocketAPI type Unprotected = AuthenticateAPI H.Html :<|> EmptyProjectPageAPI diff --git a/server/src/Utopia/Web/Types/Collaboration.hs b/server/src/Utopia/Web/Types/Collaboration.hs new file mode 100644 index 000000000000..f96304c6baf9 --- /dev/null +++ b/server/src/Utopia/Web/Types/Collaboration.hs @@ -0,0 +1,85 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RankNTypes #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeOperators #-} + +module Utopia.Web.Types.Collaboration where + +import Conduit +import Control.Lens hiding (children) +import Control.Monad.Fail +import Data.Aeson +import Data.Aeson.Lens +import qualified Data.ByteString.Lazy as BL +import qualified Data.HashMap.Strict as M +import qualified Data.Text as T +import Data.Time +import Network.OAuth.OAuth2 +import Protolude +import Servant +import Servant.API.WebSocket +import Servant.HTML.Blaze +import Servant.RawM.Server +import qualified Text.Blaze.Html5 as H +import Utopia.ClientModel +import Utopia.Web.Github.Types +import Utopia.Web.JSON +import Utopia.Web.Servant +import Utopia.Web.ServiceTypes + +data ClaimProjectControl = ClaimProjectControl + { projectID :: Text + , collaborationEditor :: Text + } deriving (Eq, Show, Generic) + +instance FromJSON ClaimProjectControl where + parseJSON = genericParseJSON defaultOptions + +data ClearAllOfCollaboratorsControl = ClearAllOfCollaboratorsControl + { collaborationEditor :: Text + } deriving (Eq, Show, Generic) + +instance FromJSON ClearAllOfCollaboratorsControl where + parseJSON = genericParseJSON defaultOptions + +data CollaborationRequest = ClaimProjectControlRequest ClaimProjectControl + | ClearAllOfCollaboratorsControlRequest ClearAllOfCollaboratorsControl + deriving (Eq, Show, Generic) + +instance FromJSON CollaborationRequest where + parseJSON value = + let fileType = firstOf (key "type" . _String) value + in case fileType of + (Just "CLAIM_PROJECT_CONTROL") -> fmap ClaimProjectControlRequest $ parseJSON value + (Just "CLEAR_ALL_OF_COLLABORATORS_CONTROL") -> fmap ClearAllOfCollaboratorsControlRequest $ parseJSON value + (Just unknownType) -> fail ("Unknown type: " <> T.unpack unknownType) + _ -> fail "No type for CollaborationRequest specified." + +data ClaimProjectControlResult = ClaimProjectControlResult + { successfullyClaimed :: Bool + } deriving (Eq, Show, Generic) + +instance ToJSON ClaimProjectControlResult where + toJSON = genericToJSON defaultOptions + +data ClearAllOfCollaboratorsControlResult = ClearAllOfCollaboratorsControlResult + deriving (Eq, Show, Generic) + +instance ToJSON ClearAllOfCollaboratorsControlResult where + toJSON _ = object [] + +data CollaborationResponse = ClaimProjectControlResultResponse ClaimProjectControlResult + | ClearAllOfCollaboratorsControlResponse ClearAllOfCollaboratorsControlResult + deriving (Eq, Show, Generic) + +instance ToJSON CollaborationResponse where + toJSON (ClaimProjectControlResultResponse claimResult) = over _Object (M.insert "type" "CLAIM_PROJECT_CONTROL_RESULT") $ toJSON claimResult + toJSON (ClearAllOfCollaboratorsControlResponse clearResult) = over _Object (M.insert "type" "CLEAR_ALL_OF_COLLABORATORS_CONTROL_RESULT") $ toJSON clearResult + +type CollaborationSocketAPI = "v1" :> "collaboration" :> ReqBody '[JSON] CollaborationRequest :> Put '[JSON] CollaborationResponse diff --git a/server/utopia-web.cabal b/server/utopia-web.cabal index ef5cbe22ceae..41b07aa038ad 100644 --- a/server/utopia-web.cabal +++ b/server/utopia-web.cabal @@ -36,6 +36,8 @@ executable utopia-web Utopia.Web.Database.Types Utopia.Web.Editor.Branches Utopia.Web.Endpoints + Utopia.Web.Endpoints.Collaboration + Utopia.Web.Endpoints.Common Utopia.Web.Exceptions Utopia.Web.Executors.Common Utopia.Web.Executors.Development @@ -56,6 +58,7 @@ executable utopia-web Utopia.Web.Server Utopia.Web.ServiceTypes Utopia.Web.Types + Utopia.Web.Types.Collaboration Utopia.Web.Utils.Files Utopia.Web.Utils.Limits Paths_utopia_web @@ -178,6 +181,8 @@ test-suite utopia-web-test Utopia.Web.Database.Types Utopia.Web.Editor.Branches Utopia.Web.Endpoints + Utopia.Web.Endpoints.Collaboration + Utopia.Web.Endpoints.Common Utopia.Web.Exceptions Utopia.Web.Executors.Common Utopia.Web.Executors.Development @@ -198,6 +203,7 @@ test-suite utopia-web-test Utopia.Web.Server Utopia.Web.ServiceTypes Utopia.Web.Types + Utopia.Web.Types.Collaboration Utopia.Web.Utils.Files Utopia.Web.Utils.Limits Paths_utopia_web From 0fe6529b7674cde59f5a005c6dbc5be1fa2a41c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bertalan=20K=C3=B6rmendy?= Date: Tue, 2 Jan 2024 17:10:18 +0100 Subject: [PATCH 2/4] close thread on resolve (#4674) --- editor/src/components/canvas/controls/comment-popup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/editor/src/components/canvas/controls/comment-popup.tsx b/editor/src/components/canvas/controls/comment-popup.tsx index 157c7818eef5..1d9b6f249753 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'))]) From af371935b089a5350f663a619ea04a00ed6f9fdb Mon Sep 17 00:00:00 2001 From: Balint Gabor <127662+gbalint@users.noreply.github.com> Date: Tue, 2 Jan 2024 17:57:34 +0100 Subject: [PATCH 3/4] Add replies counter to hovered comment indicator (#4675) --- .../canvas/controls/comment-indicator.tsx | 17 ++++++---- .../controls/comment-replies-counter.tsx | 31 +++++++++++++++++++ .../inspector/sections/comment-section.tsx | 19 ++---------- 3 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 editor/src/components/canvas/controls/comment-replies-counter.tsx 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-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/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,
)} +