Skip to content

Commit

Permalink
feature(editor) Snatching of control support. (#4711)
Browse files Browse the repository at this point in the history
- Modified `UpdateProjectServerState` to take a `Partial<ProjectServerState>`.
- Renamed `releaseControl` to `clearAllControlFromThisEditor`.
- Created `CollaborationStateUpdater` component to manage the visibility changes of the editor.
- Added `SnatchProjectControl` and `ReleaseProjectControl` to `CollaborationRequest`.
- Renamed `ClaimProjectControlResult` to `RequestProjectControlResult`.
- Renamed `ClearAllOfCollaboratorsControlResult` to `ReleaseControlResult`.
- Added `snatchControlOverProject` and releaseControlOverProject`.
- Renamed `releaseControl` to `clearAllControlFromThisEditor`.
- Modified `editorDispatchClosingOut` to use `result.projectServerState` instead of `storedState.projectServerState`.
- Modified `editorDispatchInner` to take account of `projectServerState` when determining if anything has changed.
- Changed `getProjectServerState` to return a more useful value for test environments and to check
  if the user owns a project before attempting to claim ownership.
- Added `owner_id` column to `project_collaboration`.
- Updated some database logic to take account of `owner_id` in `project_collaboration`.
- Added `forceClaimCollaborationControl` and `releaseCollaborationControl` to the database handling.
- Added `SnatchCollaborationControl` and `ReleaseCollaborationControl` to `ServiceTypes`.
- Modified `ClearCollaboratorOwnership` to take the user ID.
  • Loading branch information
seanparsons authored Jan 10, 2024
1 parent 0a25904 commit 6b2cb42
Show file tree
Hide file tree
Showing 19 changed files with 326 additions and 69 deletions.
2 changes: 1 addition & 1 deletion editor/src/components/editor/action-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,7 @@ export interface ClearPostActionSession {

export interface UpdateProjectServerState {
action: 'UPDATE_PROJECT_SERVER_STATE'
serverState: ProjectServerState
serverState: Partial<ProjectServerState>
}

export interface UpdateTopLevelElementsFromCollaborationUpdate {
Expand Down
2 changes: 1 addition & 1 deletion editor/src/components/editor/actions/action-creators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1662,7 +1662,7 @@ export function clearPostActionData(): ClearPostActionSession {
}

export function updateProjectServerState(
projectServerState: ProjectServerState,
projectServerState: Partial<ProjectServerState>,
): UpdateProjectServerState {
return {
action: 'UPDATE_PROJECT_SERVER_STATE',
Expand Down
5 changes: 4 additions & 1 deletion editor/src/components/editor/editor-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { useDisplayOwnershipWarning } from './project-owner-hooks'
import { EditorModes } from './editor-modes'
import { allowedToEditProject } from './store/collaborative-editing'
import { useDataThemeAttributeOnBody } from '../../core/commenting/comment-hooks'
import { CollaborationStateUpdater } from './store/collaboration-state'

const liveModeToastId = 'play-mode-toast'

Expand Down Expand Up @@ -556,7 +557,9 @@ export function EditorComponent(props: EditorProps) {
forkedFromProjectId={forkedFromProjectId}
dispatch={dispatch}
>
<EditorComponentInner {...props} />
<CollaborationStateUpdater projectId={projectId} dispatch={dispatch}>
<EditorComponentInner {...props} />
</CollaborationStateUpdater>
</ProjectServerStateUpdater>
</DndProvider>
</RoomProvider>
Expand Down
4 changes: 2 additions & 2 deletions editor/src/components/editor/persistence/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
userLogOutEvent,
} from './generic/persistence-machine'
import type { PersistenceBackendAPI, PersistenceContext } from './generic/persistence-types'
import { releaseControl } from '../store/collaborative-editing'
import { clearAllControlFromThisEditor } from '../store/collaborative-editing'

export class PersistenceMachine {
private interpreter: Interpreter<
Expand Down Expand Up @@ -142,7 +142,7 @@ export class PersistenceMachine {

window.addEventListener('beforeunload', async (e) => {
if (this.isSafeToClose()) {
void releaseControl()
void clearAllControlFromThisEditor()
} else {
this.sendThrottledSave()
e.preventDefault()
Expand Down
78 changes: 78 additions & 0 deletions editor/src/components/editor/store/collaboration-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react'
import type { EditorAction, EditorDispatch } from '../action-types'
import {
displayControlErrorToast,
releaseControlOverProject,
snatchControlOverProject,
} from './collaborative-editing'
import { Substores, useEditorState } from './store-hook'
import { switchEditorMode, updateProjectServerState } from '../actions/action-creators'
import { EditorModes } from '../editor-modes'

interface CollaborationStateUpdaterProps {
projectId: string | null
dispatch: EditorDispatch
}

export const CollaborationStateUpdater = React.memo(
(props: React.PropsWithChildren<CollaborationStateUpdaterProps>) => {
const { projectId, dispatch, children } = props
const isMyProject = useEditorState(
Substores.projectServerState,
(store) => store.projectServerState.isMyProject,
'CollaborationStateUpdater isMyProject',
)
React.useEffect(() => {
// If the document is hidden, that means the editor is in the background
// or minimised, so release control. Otherwise if it has become unhidden
// then snatch control of the project.
function didFocus(): void {
if (projectId != null) {
// Only attempt to do any kind of snatching of control if the project is "mine".
if (isMyProject === 'yes') {
void snatchControlOverProject(projectId)
.then((controlResult) => {
const newHolderOfTheBaton = controlResult ?? false
let actions: Array<EditorAction> = [
updateProjectServerState({ currentlyHolderOfTheBaton: newHolderOfTheBaton }),
]
// Makes sense for the editing user to be in control and they probably want to be editing
// when they regain control.
if (newHolderOfTheBaton) {
actions.push(switchEditorMode(EditorModes.selectMode(null, false, 'none')))
}
dispatch(actions)
})
.catch((error) => {
displayControlErrorToast(
dispatch,
'Error while attempting to gain control over this project.',
)
})
}
}
}
function didBlur(): void {
if (projectId != null) {
void releaseControlOverProject(projectId)
.then(() => {
dispatch([updateProjectServerState({ currentlyHolderOfTheBaton: false })])
})
.catch((error) => {
displayControlErrorToast(
dispatch,
'Error while attempting to release control over this project.',
)
})
}
}
window.addEventListener('focus', didFocus)
window.addEventListener('blur', didBlur)
return () => {
window.removeEventListener('focus', didFocus)
window.removeEventListener('blur', didBlur)
}
}, [dispatch, projectId, isMyProject])
return <>{children}</>
},
)
91 changes: 82 additions & 9 deletions editor/src/components/editor/store/collaborative-editing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
deleteFileFromCollaboration,
showToast,
updateCodeFromCollaborationUpdate,
updateExportsDetailFromCollaborationUpdate,
updateImportsFromCollaborationUpdate,
Expand Down Expand Up @@ -51,6 +52,7 @@ 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'
import { notice } from '../../common/notice'

const CodeKey = 'code'
const TopLevelElementsKey = 'topLevelElements'
Expand Down Expand Up @@ -630,6 +632,40 @@ export function claimProjectControl(
}
}

export interface SnatchProjectControl {
type: 'SNATCH_PROJECT_CONTROL'
projectID: string
collaborationEditor: string
}

export function snatchProjectControl(
projectID: string,
collaborationEditor: string,
): SnatchProjectControl {
return {
type: 'SNATCH_PROJECT_CONTROL',
projectID: projectID,
collaborationEditor: collaborationEditor,
}
}

export interface ReleaseProjectControl {
type: 'RELEASE_PROJECT_CONTROL'
projectID: string
collaborationEditor: string
}

export function releaseProjectControl(
projectID: string,
collaborationEditor: string,
): ReleaseProjectControl {
return {
type: 'RELEASE_PROJECT_CONTROL',
projectID: projectID,
collaborationEditor: collaborationEditor,
}
}

export interface ClearAllOfCollaboratorsControl {
type: 'CLEAR_ALL_OF_COLLABORATORS_CONTROL'
collaborationEditor: string
Expand All @@ -644,18 +680,22 @@ export function clearAllOfCollaboratorsControl(
}
}

export type CollaborationRequest = ClaimProjectControl | ClearAllOfCollaboratorsControl
export type CollaborationRequest =
| ClaimProjectControl
| SnatchProjectControl
| ReleaseProjectControl
| ClearAllOfCollaboratorsControl

export interface ClaimProjectControlResult {
type: 'CLAIM_PROJECT_CONTROL_RESULT'
export interface RequestProjectControlResult {
type: 'REQUEST_PROJECT_CONTROL_RESULT'
successfullyClaimed: boolean
}

export interface ClearAllOfCollaboratorsControlResult {
type: 'CLEAR_ALL_OF_COLLABORATORS_CONTROL_RESULT'
export interface ReleaseControlResponse {
type: 'RELEASE_CONTROL_RESULT'
}

export type CollaborationResponse = ClaimProjectControlResult | ClearAllOfCollaboratorsControlResult
export type CollaborationResponse = RequestProjectControlResult | ReleaseControlResponse

const collaborationEditor = UUID()

Expand All @@ -681,22 +721,55 @@ export async function claimControlOverProject(projectID: string | null): Promise
if (projectID == null || !isFeatureEnabled('Baton Passing For Control')) {
return null
}
if (!document.hasFocus()) {
return false
}

const request = claimProjectControl(projectID, collaborationEditor)
const response = await callCollaborationEndpoint(request)
if (response.type === 'CLAIM_PROJECT_CONTROL_RESULT') {
if (response.type === 'REQUEST_PROJECT_CONTROL_RESULT') {
return response.successfullyClaimed
} else {
throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
}
}

export async function releaseControl(): Promise<void> {
export async function snatchControlOverProject(projectID: string | null): Promise<boolean | null> {
if (projectID == null || !isFeatureEnabled('Baton Passing For Control')) {
return null
}

const request = snatchProjectControl(projectID, collaborationEditor)
const response = await callCollaborationEndpoint(request)
if (response.type === 'REQUEST_PROJECT_CONTROL_RESULT') {
return response.successfullyClaimed
} else {
throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
}
}

export async function releaseControlOverProject(projectID: string | null): Promise<void> {
if (projectID == null || !isFeatureEnabled('Baton Passing For Control')) {
return
}

const request = releaseProjectControl(projectID, collaborationEditor)
const response = await callCollaborationEndpoint(request)
if (response.type !== 'RELEASE_CONTROL_RESULT') {
throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
}
}

export function displayControlErrorToast(dispatch: EditorDispatch, message: string): void {
dispatch([showToast(notice(message, 'ERROR', false, 'control-error'))])
}

export async function clearAllControlFromThisEditor(): Promise<void> {
if (isFeatureEnabled('Baton Passing For Control')) {
const request = clearAllOfCollaboratorsControl(collaborationEditor)

const response = await callCollaborationEndpoint(request)
if (response.type !== 'CLEAR_ALL_OF_COLLABORATORS_CONTROL_RESULT') {
if (response.type !== 'RELEASE_CONTROL_RESULT') {
throw new Error(`Unexpected response: ${JSON.stringify(response)}`)
}
}
Expand Down
3 changes: 2 additions & 1 deletion editor/src/components/editor/store/dispatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@ export function editorDispatchClosingOut(
]),
saveCountThisSession: saveCountThisSession + (shouldSave ? 1 : 0),
builtInDependencies: storedState.builtInDependencies,
projectServerState: storedState.projectServerState,
projectServerState: result.projectServerState,
collaborativeEditingSupport: storedState.collaborativeEditingSupport,
}

Expand Down Expand Up @@ -830,6 +830,7 @@ function editorDispatchInner(
storedState.nothingChanged &&
storedState.unpatchedEditor === result.unpatchedEditor &&
storedState.userState === result.userState &&
storedState.projectServerState === result.projectServerState &&
storedState.postActionInteractionSession === result.postActionInteractionSession

const domMetadataChanged =
Expand Down
5 changes: 4 additions & 1 deletion editor/src/components/editor/store/editor-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,9 @@ export function runUpdateProjectServerState(
): EditorStoreUnpatched {
return {
...working,
projectServerState: action.serverState,
projectServerState: {
...working.projectServerState,
...action.serverState,
},
}
}
25 changes: 21 additions & 4 deletions editor/src/components/editor/store/project-server-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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'
import { claimControlOverProject, displayControlErrorToast } from './collaborative-editing'

export interface ProjectMetadataFromServer {
title: string
Expand Down Expand Up @@ -76,11 +76,16 @@ function projectListingToProjectMetadataFromServer(
}

export async function getProjectServerState(
dispatch: EditorDispatch,
projectId: string | null,
forkedFromProjectId: string | null,
): Promise<ProjectServerState> {
if (IS_TEST_ENVIRONMENT) {
return emptyProjectServerState()
return {
...emptyProjectServerState(),
isMyProject: 'yes',
currentlyHolderOfTheBaton: true,
}
} else {
const projectListing = projectId == null ? null : await fetchProjectMetadata(projectId)
const forkedFromProjectListing =
Expand All @@ -95,7 +100,19 @@ export async function getProjectServerState(
}
const ownership = await getOwnership()

const holderOfTheBaton = (await claimControlOverProject(projectId)) ?? ownership.isOwner
const holderOfTheBaton = ownership.isOwner
? await claimControlOverProject(projectId)
.then((result) => {
return result ?? ownership.isOwner
})
.catch(() => {
displayControlErrorToast(
dispatch,
'Error while attempting to claim control over this project.',
)
return false
})
: false

return {
isMyProject: ownership.isOwner ? 'yes' : 'no',
Expand All @@ -112,7 +129,7 @@ export function updateProjectServerStateInStore(
forkedFromProjectId: string | null,
dispatch: EditorDispatch,
) {
void getProjectServerState(projectId, forkedFromProjectId)
void getProjectServerState(dispatch, projectId, forkedFromProjectId)
.then((serverState) => {
dispatch([updateProjectServerState(serverState)], 'everyone')
})
Expand Down
2 changes: 2 additions & 0 deletions server/migrations/005.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE ONLY "public"."project_collaboration"
ADD COLUMN "owner_id" character varying;
Loading

0 comments on commit 6b2cb42

Please sign in to comment.