Skip to content

Commit

Permalink
feature(editor) Snatching of control support.
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 committed Jan 9, 2024
1 parent 1b1c4d4 commit 9d4be8d
Show file tree
Hide file tree
Showing 19 changed files with 278 additions and 67 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 @@ -1052,7 +1052,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 @@ -1659,7 +1659,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 @@ -136,7 +136,7 @@ export class PersistenceMachine {

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

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(() => {
function handleVisibilityChange(): void {
if (projectId != null) {
// 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.
if (document.hidden) {
void releaseControlOverProject(projectId).then(() => {
dispatch([updateProjectServerState({ currentlyHolderOfTheBaton: false })])
})
} else {
// Only attempt to do any kind of snatching of control if the project is "mine".
if (isMyProject === 'yes') {
void snatchControlOverProject(projectId).then((controlResult) => {
dispatch([
updateProjectServerState({ currentlyHolderOfTheBaton: controlResult ?? false }),
])
})
}
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [dispatch, projectId, isMyProject])
return <>{children}</>
},
)
85 changes: 76 additions & 9 deletions editor/src/components/editor/store/collaborative-editing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,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 +678,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 +719,51 @@ export async function claimControlOverProject(projectID: string | null): Promise
if (projectID == null || !isFeatureEnabled('Baton Passing For Control')) {
return null
}
if (document.hidden) {
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 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 releaseControl(): Promise<void> {
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 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,
},
}
}
10 changes: 8 additions & 2 deletions editor/src/components/editor/store/project-server-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export async function getProjectServerState(
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 +99,9 @@ export async function getProjectServerState(
}
const ownership = await getOwnership()

const holderOfTheBaton = (await claimControlOverProject(projectId)) ?? ownership.isOwner
const holderOfTheBaton = ownership.isOwner
? (await claimControlOverProject(projectId)) ?? ownership.isOwner
: false

return {
isMyProject: ownership.isOwner ? 'yes' : 'no',
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 9d4be8d

Please sign in to comment.