diff --git a/package.json b/package.json index d7ef31c115..d0805914b0 100644 --- a/package.json +++ b/package.json @@ -33,17 +33,18 @@ "@blueprintjs/datetime2": "^2.3.3", "@blueprintjs/icons": "^6.0.0", "@blueprintjs/select": "^5.1.3", + "@convergencelabs/ace-collab-ext": "^0.6.0", "@mantine/hooks": "^7.11.2", "@octokit/rest": "^20.0.0", "@reduxjs/toolkit": "^1.9.7", "@sentry/browser": "^8.33.0", "@sourceacademy/c-slang": "^1.0.21", - "@sourceacademy/sharedb-ace": "^2.0.3", + "@sourceacademy/sharedb-ace": "^2.1.0", "@sourceacademy/sling-client": "^0.1.0", "@szhsin/react-menu": "^4.0.0", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^1.8.2", - "ace-builds": "^1.36.3", + "ace-builds": "^1.42.1", "acorn": "^8.9.0", "ag-grid-community": "^32.3.1", "ag-grid-react": "^32.3.1", @@ -58,6 +59,7 @@ "hastscript": "^9.0.0", "i18next": "^25.0.0", "i18next-browser-languagedetector": "^8.0.0", + "immer": "^10.1.1", "java-slang": "^1.0.13", "js-cookie": "^3.0.5", "js-slang": "^1.0.84", @@ -113,7 +115,6 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/preset-typescript": "^7.24.1", "@babel/runtime": "^7.24.5", - "@convergencelabs/ace-collab-ext": "^0.6.0", "@rsbuild/core": "^1.3.12", "@rsbuild/plugin-eslint": "^1.1.1", "@rsbuild/plugin-node-polyfill": "^1.3.0", @@ -126,7 +127,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.4.3", + "@testing-library/user-event": "^14.6.0", "@types/estree": "^1.0.5", "@types/gapi": "^0.0.47", "@types/gapi.auth2": "^0.0.61", diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 9f91a27e1f..87bc1e9e2d 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -418,7 +418,8 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo enableDebugging: true, debuggerContext: {} as DebuggerContext, lastDebuggerResult: undefined, - files: {} + files: {}, + updateUserRoleCallback: () => {} }); const defaultFileName = 'program.js'; diff --git a/src/commons/collabEditing/CollabEditingActions.ts b/src/commons/collabEditing/CollabEditingActions.ts index 61b5516859..f9c80fcfd6 100644 --- a/src/commons/collabEditing/CollabEditingActions.ts +++ b/src/commons/collabEditing/CollabEditingActions.ts @@ -1,3 +1,5 @@ +import type { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types'; + import { createActions } from '../redux/utils'; import { WorkspaceLocation } from '../workspace/WorkspaceTypes'; @@ -8,7 +10,7 @@ const CollabEditingActions = createActions('collabEditing', { }), setSessionDetails: ( workspaceLocation: WorkspaceLocation, - sessionDetails: { docId: string; readOnly: boolean } | null + sessionDetails: { docId?: string; readOnly?: boolean; owner?: boolean } | null ) => ({ workspaceLocation, sessionDetails }), /** * Sets ShareDB connection status. @@ -19,11 +21,23 @@ const CollabEditingActions = createActions('collabEditing', { setSharedbConnected: (workspaceLocation: WorkspaceLocation, connected: boolean) => ({ workspaceLocation, connected + }), + setUpdateUserRoleCallback: ( + workspaceLocation: WorkspaceLocation, + updateUserRoleCallback: (id: string, newRole: CollabEditingAccess) => void + ) => ({ + workspaceLocation, + updateUserRoleCallback }) }); // For compatibility with existing code (reducer) -export const { setEditorSessionId, setSessionDetails, setSharedbConnected } = CollabEditingActions; +export const { + setEditorSessionId, + setSessionDetails, + setSharedbConnected, + setUpdateUserRoleCallback +} = CollabEditingActions; // For compatibility with existing code (actions helper) export default CollabEditingActions; diff --git a/src/commons/collabEditing/CollabEditingHelper.ts b/src/commons/collabEditing/CollabEditingHelper.ts index c7a5d29ee8..e191c3840e 100644 --- a/src/commons/collabEditing/CollabEditingHelper.ts +++ b/src/commons/collabEditing/CollabEditingHelper.ts @@ -13,9 +13,17 @@ export function getSessionUrl(sessionId: string, ws?: boolean): string { return url.toString(); } +export function getPlaygroundSessionUrl(sessionId: string): string { + let url = window.location.href; + if (window.location.href.endsWith('/playground')) { + url += `/${sessionId}`; + } + return url; +} + export async function getDocInfoFromSessionId( sessionId: string -): Promise<{ docId: string; readOnly: boolean } | null> { +): Promise<{ docId: string; defaultReadOnly: boolean } | null> { const resp = await fetch(getSessionUrl(sessionId)); if (resp && resp.ok) { @@ -27,7 +35,7 @@ export async function getDocInfoFromSessionId( export async function createNewSession( contents: string -): Promise<{ docId: string; sessionEditingId: string; sessionViewingId: string }> { +): Promise<{ docId: string; sessionId: string }> { const resp = await fetch(Constants.sharedbBackendUrl, { method: 'POST', body: JSON.stringify({ contents }), @@ -42,3 +50,19 @@ export async function createNewSession( return resp.json(); } + +export async function changeDefaultEditable(sessionId: string, defaultReadOnly: boolean) { + const resp = await fetch(getSessionUrl(sessionId), { + method: 'PATCH', + body: JSON.stringify({ defaultReadOnly }), + headers: { 'Content-Type': 'application/json' } + }); + + if (!resp || !resp.ok) { + throw new Error( + resp ? `Could not update session: ${await resp.text()}` : 'Unknown error updating session' + ); + } + + return resp.json(); +} diff --git a/src/commons/controlBar/ControlBarSessionButton.tsx b/src/commons/controlBar/ControlBarSessionButton.tsx index 915f73b174..2368ffff4c 100644 --- a/src/commons/controlBar/ControlBarSessionButton.tsx +++ b/src/commons/controlBar/ControlBarSessionButton.tsx @@ -1,26 +1,20 @@ -import { - Classes, - Colors, - Divider, - FormGroup, - Menu, - Popover, - Text, - Tooltip -} from '@blueprintjs/core'; +import { Classes, Colors, Divider, FormGroup, Popover, Text, Tooltip } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import * as CopyToClipboard from 'react-copy-to-clipboard'; +import { useParams } from 'react-router'; import { createNewSession, getDocInfoFromSessionId } from '../collabEditing/CollabEditingHelper'; import ControlButton from '../ControlButton'; -import { showWarningMessage } from '../utils/notifications/NotificationsHelper'; +import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; type ControlBarSessionButtonsProps = DispatchProps & StateProps; type DispatchProps = { handleSetEditorSessionId?: (editorSessionId: string) => void; - handleSetSessionDetails?: (sessionDetails: { docId: string; readOnly: boolean } | null) => void; + handleSetSessionDetails?: ( + sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null + ) => void; }; type StateProps = { @@ -31,215 +25,177 @@ type StateProps = { key: string; }; -type State = { - joinElemValue: string; - sessionEditingId: string; - sessionViewingId: string; -}; - function handleError(error: any) { showWarningMessage(`Could not connect: ${(error && error.message) || error || 'Unknown error'}`); } -export class ControlBarSessionButtons extends React.PureComponent< - ControlBarSessionButtonsProps, - State -> { - private sessionEditingIdInputElem: React.RefObject; - private sessionViewingIdInputElem: React.RefObject; - - constructor(props: ControlBarSessionButtonsProps) { - super(props); - this.state = { joinElemValue: '', sessionEditingId: '', sessionViewingId: '' }; - - this.handleChange = this.handleChange.bind(this); - this.sessionEditingIdInputElem = React.createRef(); - this.sessionViewingIdInputElem = React.createRef(); - this.selectSessionEditingId = this.selectSessionEditingId.bind(this); - this.selectSessionViewingId = this.selectSessionViewingId.bind(this); - } - - public render() { - const handleStartInvite = () => { - // FIXME this handler should be a Saga action or at least in a controller - if (this.props.editorSessionId === '') { - createNewSession(this.props.getEditorValue()).then(resp => { - this.setState({ - sessionEditingId: resp.sessionEditingId, - sessionViewingId: resp.sessionViewingId - }); - this.props.handleSetEditorSessionId!(resp.sessionEditingId); - this.props.handleSetSessionDetails!({ docId: resp.docId, readOnly: false }); - }, handleError); - } - }; - - const inviteButtonPopoverContent = ( -
- {!this.props.editorSessionId ? ( - <> - You are not currently in any session. - - - - ) : ( - <> - - You have joined the session as{' '} - {this.state.sessionEditingId ? 'an editor' : 'a viewer'}. - - - {this.state.sessionEditingId && ( - - - - - - - )} - {this.state.sessionViewingId && ( - - - - - - - )} - - )} -
- ); - - const inviteButton = ( - - - - ); +export function ControlBarSessionButtons(props: ControlBarSessionButtonsProps) { + const joinElemRef = useRef(''); + const [sessionId, setSessionId] = useState(''); + const [defaultReadOnly, setDefaultReadOnly] = useState(true); + const [isOwner, setIsOwner] = useState(false); + + const handleChange = (event: React.ChangeEvent) => { + joinElemRef.current = event.target.value; + }; + + const handleStartInvite = () => { + // FIXME this handler should be a Saga action or at least in a controller + if (props.editorSessionId === '') { + createNewSession(props.getEditorValue()).then(resp => { + setSessionId(resp.sessionId); + props.handleSetEditorSessionId!(resp.sessionId); + props.handleSetSessionDetails!({ docId: resp.docId, readOnly: false, owner: true }); + setIsOwner(true); + }, handleError); + } + }; - const handleStartJoining = (event: React.FormEvent) => { + const handleStartJoining = useCallback( + (event: React.FormEvent) => { event.preventDefault(); + const joinElemValue = joinElemRef.current; + // FIXME this handler should be a Saga action or at least in a controller - getDocInfoFromSessionId(this.state.joinElemValue).then( + getDocInfoFromSessionId(joinElemValue).then( docInfo => { if (docInfo !== null) { - this.props.handleSetEditorSessionId!(this.state!.joinElemValue); - this.props.handleSetSessionDetails!(docInfo); - if (docInfo.readOnly) { - this.setState({ - sessionEditingId: '', - sessionViewingId: this.state.joinElemValue - }); - } else { - this.setState({ - sessionEditingId: this.state.joinElemValue, - sessionViewingId: '' - }); - } + props.handleSetEditorSessionId!(joinElemValue); + props.handleSetSessionDetails!({ + docId: docInfo.docId, + readOnly: docInfo.defaultReadOnly, + owner: false + }); + setSessionId(joinElemValue); + setDefaultReadOnly(docInfo.defaultReadOnly); + setIsOwner(false); } else { - this.props.handleSetEditorSessionId!(''); - this.props.handleSetSessionDetails!(null); + props.handleSetEditorSessionId!(''); + props.handleSetSessionDetails!(null); showWarningMessage('Could not find a session with that ID.'); + if ( + window.location.href.includes('/playground') && + !window.location.href.endsWith('/playground') + ) { + window.history.pushState({}, document.title, '/playground'); + } } }, error => { - this.props.handleSetEditorSessionId!(''); + props.handleSetEditorSessionId!(''); handleError(error); } ); - }; - - const joinButtonPopoverContent = ( - // TODO: this form should use Blueprint -
- - - - -
- ); - - const joinButton = ( - - - - ); - - const leaveButton = ( - { - // FIXME: this handler should be a Saga action or at least in a controller - this.props.handleSetEditorSessionId!(''); - this.setState({ joinElemValue: '', sessionEditingId: '', sessionViewingId: '' }); - }} - /> - ); - - const tooltipContent = this.props.isFolderModeEnabled - ? 'Currently unsupported in Folder mode' - : undefined; - - return ( - - - {inviteButton} - {this.props.editorSessionId === '' ? joinButton : leaveButton} - - } - disabled={this.props.isFolderModeEnabled} + }, + [props.handleSetEditorSessionId, props.handleSetSessionDetails] + ); + + const leaveButton = ( + { + // FIXME: this handler should be a Saga action or at least in a controller + props.handleSetEditorSessionId!(''); + joinElemRef.current = ''; + setSessionId(''); + }} + /> + ); + + const inviteButtonPopoverContent = ( +
+ {!props.editorSessionId ? ( +
+ You are not currently in any session. + - - - ); - } - - private selectSessionEditingId() { - if (this.sessionEditingIdInputElem.current !== null) { - this.sessionEditingIdInputElem.current.focus(); - this.sessionEditingIdInputElem.current.select(); - } - } - - private selectSessionViewingId() { - if (this.sessionViewingIdInputElem.current !== null) { - this.sessionViewingIdInputElem.current.focus(); - this.sessionViewingIdInputElem.current.select(); +
+ ... or join an existing one +
+
+ + + + +
+
+ ) : ( +
+ + You have joined the session as{' '} + {isOwner ? 'the owner' : defaultReadOnly ? 'a viewer' : 'an editor'}. + + + {sessionId && ( +
+ + + + showSuccessMessage('Copied to clipboard: ' + sessionId)} + > + + +
+ )} + {leaveButton} +
+ )} +
+ ); + + const tooltipContent = props.isFolderModeEnabled + ? 'Currently unsupported in Folder mode' + : undefined; + + const { playgroundCode } = useParams<{ playgroundCode: string }>(); + useEffect(() => { + if (playgroundCode) { + joinElemRef.current = playgroundCode; + handleStartJoining({ preventDefault: () => {} } as React.FormEvent); } - } - - private handleChange(event: React.ChangeEvent) { - this.setState({ joinElemValue: event.target.value }); - } + }, [playgroundCode, handleStartJoining]); + + return ( + + + + + + ); } diff --git a/src/commons/editor/Editor.tsx b/src/commons/editor/Editor.tsx index e941b3ec36..31127f0160 100644 --- a/src/commons/editor/Editor.tsx +++ b/src/commons/editor/Editor.tsx @@ -25,6 +25,7 @@ import { Chapter, Variant } from 'js-slang/dist/types'; import React from 'react'; import AceEditor, { IAceEditorProps, IEditorProps } from 'react-ace'; import { IAceEditor } from 'react-ace/lib/types'; +import { SALanguage } from '../application/ApplicationTypes'; import { EditorBinding } from '../WorkspaceSettingsContext'; import { getModeString, selectMode } from '../utils/AceHelper'; import { objectEntries } from '../utils/TypeHelper'; @@ -38,6 +39,8 @@ import useHighlighting from './UseHighlighting'; import useNavigation from './UseNavigation'; import useRefactor from './UseRefactor'; import useShareAce from './UseShareAce'; +import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types'; +import { ExternalLibraryName } from '../application/types/ExternalTypes'; export type EditorKeyBindingHandlers = { [name in KeyFunction]?: () => void }; export type EditorHook = ( @@ -62,13 +65,16 @@ type DispatchProps = { type EditorStateProps = { editorSessionId: string; - sessionDetails: { docId: string; readOnly: boolean } | null; + sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null; isEditorAutorun: boolean; sourceChapter?: Chapter; externalLibraryName?: string; sourceVariant?: Variant; hooks?: EditorHook[]; editorBinding?: EditorBinding; + setUsers?: React.Dispatch>>; + // TODO: Handle changing of external library + updateLanguageCallback?: (sublanguage: SALanguage, e: any) => void; }; export type EditorTabStateProps = { @@ -358,22 +364,7 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { // See AceHelper#selectMode for more information. props.session.setMode(editor.getSession().getMode()); editor.setSession(props.session); - /* eslint-disable */ - - // Add changeCursor event listener onto the current session. - // In ReactAce, this event listener is only bound on component - // mounting/creation, and hence changing sessions will need rebinding. - // See react-ace/src/ace.tsx#263,#460 for more details. We also need to - // ensure that event listener is only bound once to prevent memory leaks. - // We also need to check non-documented property _eventRegistry to - // see if the changeCursor listener event has been added yet. - - // @ts-ignore - if (editor.getSession().selection._eventRegistry.changeCursor.length < 2) { - editor.getSession().selection.on('changeCursor', reactAceRef.current!.onCursorChange); - } - /* eslint-enable */ // Give focus to the editor tab only after switching from another tab. // This is necessary to prevent 'unstable_flushDiscreteUpdates' warnings. if (filePath !== undefined) { @@ -412,7 +403,7 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { const [sourceChapter, sourceVariant, externalLibraryName] = [ props.sourceChapter || Chapter.SOURCE_1, props.sourceVariant || Variant.DEFAULT, - props.externalLibraryName || 'NONE' + props.externalLibraryName || ExternalLibraryName.NONE ]; // this function defines the Ace language and highlighting mode for the @@ -660,8 +651,9 @@ const EditorBase = React.memo((props: EditorProps & LocalStateProps) => { return ( -
+
+
); diff --git a/src/commons/editor/UseShareAce.tsx b/src/commons/editor/UseShareAce.tsx index 8a08ba0aba..61816a7fb9 100644 --- a/src/commons/editor/UseShareAce.tsx +++ b/src/commons/editor/UseShareAce.tsx @@ -1,11 +1,21 @@ import '@convergencelabs/ace-collab-ext/dist/css/ace-collab-ext.css'; -import { AceMultiCursorManager } from '@convergencelabs/ace-collab-ext'; +import { + AceMultiCursorManager, + AceMultiSelectionManager, + AceRadarView +} from '@convergencelabs/ace-collab-ext'; import * as Sentry from '@sentry/browser'; import sharedbAce from '@sourceacademy/sharedb-ace'; -import React, { useMemo } from 'react'; +import type SharedbAceBinding from '@sourceacademy/sharedb-ace/binding'; +import { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { getLanguageConfig } from '../application/ApplicationTypes'; +import CollabEditingActions from '../collabEditing/CollabEditingActions'; import { getDocInfoFromSessionId, getSessionUrl } from '../collabEditing/CollabEditingHelper'; +import { parseModeString } from '../utils/AceHelper'; import { useSession } from '../utils/Hooks'; import { showSuccessMessage } from '../utils/notifications/NotificationsHelper'; import { EditorHook } from './Editor'; @@ -17,48 +27,116 @@ import { EditorHook } from './Editor'; // keyBindings allow exporting new hotkeys // reactAceRef is the underlying reactAce instance for hooking. +const color = getColor(); + const useShareAce: EditorHook = (inProps, outProps, keyBindings, reactAceRef) => { // use a ref to refer to any other props so that we run the effect below - // *only* when the editorSessionId changes + // *only* when the editorSessionId or sessionDetails changes const propsRef = React.useRef(inProps); propsRef.current = inProps; const { editorSessionId, sessionDetails } = inProps; - - const { name } = useSession(); - - const user = useMemo(() => ({ name, color: getColor() }), [name]); + const { name, userId } = useSession(); + const dispatch = useDispatch(); React.useEffect(() => { if (!editorSessionId || !sessionDetails) { return; } + const collabEditorAccess = sessionDetails.owner + ? CollabEditingAccess.OWNER + : sessionDetails.readOnly + ? CollabEditingAccess.VIEWER + : CollabEditingAccess.EDITOR; + + const user = { + name: name || 'Unnamed user', + color, + role: collabEditorAccess + }; + const editor = reactAceRef.current!.editor; - const cursorManager = new AceMultiCursorManager(editor.getSession()); + const session = editor.getSession(); + // TODO: Hover over the indicator to show the username as well + const cursorManager = new AceMultiCursorManager(session); + const selectionManager = new AceMultiSelectionManager(session); + const radarManager = new AceRadarView('ace-radar-view', editor); + + // @ts-expect-error hotfix to remove all views in radarManager + radarManager.removeAllViews = () => { + // @ts-expect-error hotfix to remove all views in radarManager + for (const id in radarManager._views) { + radarManager.removeView(id); + } + }; + const ShareAce = new sharedbAce(sessionDetails.docId, { user, - cursorManager, WsUrl: getSessionUrl(editorSessionId, true), - pluginWsUrl: null, namespace: 'sa' }); - ShareAce.on('ready', () => { - ShareAce.add(editor, cursorManager, ['contents'], []); + const updateUsers = (binding: SharedbAceBinding) => { + if (binding.connectedUsers === undefined) { + return; + } + propsRef.current.setUsers?.(binding.connectedUsers); + const myUserId = Object.keys(ShareAce.usersPresence.localPresences)[0]; + if (binding.connectedUsers[myUserId].role !== user.role) { + // Change in role, update readOnly status in sessionDetails + dispatch( + CollabEditingActions.setSessionDetails('playground', { + readOnly: binding.connectedUsers[myUserId].role === CollabEditingAccess.VIEWER + }) + ); + } + }; + + const shareAceReady = () => { + if (!sessionDetails) { + return; + } + const binding = ShareAce.add( + editor, + ['contents'], + { + cursorManager, + selectionManager, + radarManager + }, + { + languageSelectHandler: (language: string) => { + const { chapter, variant } = parseModeString(language); + propsRef.current.updateLanguageCallback?.(getLanguageConfig(chapter, variant), null); + } + } + ); propsRef.current.handleSetSharedbConnected!(true); + dispatch( + CollabEditingActions.setUpdateUserRoleCallback('playground', binding.changeUserRole) + ); // Disables editor in a read-only session editor.setReadOnly(sessionDetails.readOnly); + navigator.clipboard.writeText(editorSessionId).then(() => { + showSuccessMessage( + `You have joined a session as ${sessionDetails.readOnly ? 'a viewer' : 'an editor'}. Copied to clipboard: ${editorSessionId}` + ); + }); + + updateUsers(binding); + binding.usersPresence.on('receive', () => updateUsers(binding)); + window.history.pushState({}, document.title, '/playground/' + editorSessionId); + }; - showSuccessMessage( - 'You have joined a session as ' + (sessionDetails.readOnly ? 'a viewer.' : 'an editor.') - ); - }); - ShareAce.on('error', (path: string, error: any) => { + const shareAceError = (path: string, error: any) => { console.error('ShareAce error', error); Sentry.captureException(error); - }); + }; + + ShareAce.on('ready', shareAceReady); + ShareAce.on('error', shareAceError); // WebSocket connection status detection logic const WS = ShareAce.WS; @@ -96,13 +174,29 @@ const useShareAce: EditorHook = (inProps, outProps, keyBindings, reactAceRef) => } ShareAce.WS.close(); + ShareAce.off('ready', shareAceReady); + ShareAce.off('error', shareAceError); + // Resets editor to normal after leaving the session editor.setReadOnly(false); // Removes all cursors cursorManager.removeAll(); + + // Removes all selections + selectionManager.removeAll(); + + // @ts-expect-error hotfix to remove all views in radarManager + radarManager.removeAllViews(); + + if ( + window.location.href.includes('/playground') && + !window.location.href.endsWith('/playground') + ) { + window.history.pushState({}, document.title, '/playground'); + } }; - }, [editorSessionId, sessionDetails, reactAceRef, user]); + }, [editorSessionId, sessionDetails, reactAceRef, userId, name, dispatch]); }; function getColor() { diff --git a/src/commons/navigationBar/NavigationBar.tsx b/src/commons/navigationBar/NavigationBar.tsx index 01b0c770f4..5bb3a65e6c 100644 --- a/src/commons/navigationBar/NavigationBar.tsx +++ b/src/commons/navigationBar/NavigationBar.tsx @@ -258,7 +258,7 @@ const NavigationBar: React.FC = () => { const commonNavbarRight = ( - {location.pathname.startsWith('/playground') && } + classNames('NavigationBar__link', Classes.BUTTON, Classes.MINIMAL, { @@ -302,7 +302,7 @@ const NavigationBar: React.FC = () => { - + diff --git a/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx b/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx index 553ad6fd26..2e3f7db798 100644 --- a/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx +++ b/src/commons/navigationBar/subcomponents/__tests__/NavigationBarLangSelectButton.tsx @@ -1,6 +1,5 @@ import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; +import userEvent, { type UserEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { SupportedLanguage } from '../../../../commons/application/ApplicationTypes'; diff --git a/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx b/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx index c52c9a999e..b45ee96697 100644 --- a/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx +++ b/src/commons/researchAgreementPrompt/__tests__/ResearchAgreementPrompt.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { Provider, useDispatch } from 'react-redux'; import SessionActions from 'src/commons/application/actions/SessionActions'; import { mockInitialStore } from 'src/commons/mocks/StoreMocks'; diff --git a/src/commons/sagas/SideContentSaga.ts b/src/commons/sagas/SideContentSaga.ts index 2f99877591..59fa2ed563 100644 --- a/src/commons/sagas/SideContentSaga.ts +++ b/src/commons/sagas/SideContentSaga.ts @@ -4,12 +4,15 @@ import StoriesActions from 'src/features/stories/StoriesActions'; import { combineSagaHandlers } from '../redux/utils'; import SideContentActions from '../sideContent/SideContentActions'; +import { SideContentType } from '../sideContent/SideContentTypes'; import WorkspaceActions from '../workspace/WorkspaceActions'; const isSpawnSideContent = ( action: Action ): action is ReturnType => - action.type === SideContentActions.spawnSideContent.type; + action.type === SideContentActions.spawnSideContent.type || + (action as any).payload?.id !== SideContentType.sessionManagement; +// hotfix check here to allow for blinking during session update const SideContentSaga = combineSagaHandlers({ [SideContentActions.beginAlertSideContent.type]: function* ({ diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts index f4598968ca..b45e457392 100644 --- a/src/commons/sideContent/SideContentTypes.ts +++ b/src/commons/sideContent/SideContentTypes.ts @@ -26,6 +26,7 @@ export enum SideContentType { questionOverview = 'question_overview', remoteExecution = 'remote_execution', scoreLeaderboard = 'score_leaderboard', + sessionManagement = 'session_management', missionMetadata = 'mission_metadata', mobileEditor = 'mobile_editor', mobileEditorRun = 'mobile_editor_run', diff --git a/src/commons/sideContent/content/SideContentSessionManagement.tsx b/src/commons/sideContent/content/SideContentSessionManagement.tsx new file mode 100644 index 0000000000..99c1f307ba --- /dev/null +++ b/src/commons/sideContent/content/SideContentSessionManagement.tsx @@ -0,0 +1,210 @@ +import { Classes, HTMLTable, Icon, Switch } from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { CollabEditingAccess, type SharedbAceUser } from '@sourceacademy/sharedb-ace/types'; +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { useDispatch } from 'react-redux'; +import { + changeDefaultEditable, + getPlaygroundSessionUrl +} from 'src/commons/collabEditing/CollabEditingHelper'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { showSuccessMessage } from 'src/commons/utils/notifications/NotificationsHelper'; +import classes from 'src/styles/SideContentSessionManagement.module.scss'; + +import { beginAlertSideContent } from '../SideContentActions'; +import { SideContentLocation, SideContentType } from '../SideContentTypes'; + +interface AdminViewProps { + users: Record; + playgroundCode: string; + defaultReadOnly: boolean; +} + +function AdminView({ users, playgroundCode }: AdminViewProps) { + const [toggleAll, setToggleAll] = useState(true); + const [defaultRole, setDefaultRole] = useState(true); + const [toggling, setToggling] = useState<{ [key: string]: boolean }>( + Object.fromEntries(Object.entries(users).map(([id]) => [id, true])) + ); + const updateUserRoleCallback = useTypedSelector( + store => store.workspaces.playground.updateUserRoleCallback + ); + + const handleToggleAccess = (checked: boolean, id: string) => { + if (toggling[id]) return; + setToggling(prev => ({ ...prev, [id]: true })); + + try { + updateUserRoleCallback(id, checked ? CollabEditingAccess.EDITOR : CollabEditingAccess.VIEWER); + } finally { + setToggling(prev => ({ ...prev, [id]: false })); + } + }; + + const handleAllToggleAccess = (checked: boolean) => { + try { + Object.keys(users).forEach(userId => { + if (userId !== 'all') { + updateUserRoleCallback( + userId, + checked ? CollabEditingAccess.EDITOR : CollabEditingAccess.VIEWER + ); + } + }); + } finally { + setToggleAll(checked); + } + }; + + const handleDefaultToggleAccess = (checked: boolean) => { + changeDefaultEditable(playgroundCode, !checked); + setDefaultRole(checked); + return; + }; + + return ( + <> + + Toggle all roles in current session: + handleAllToggleAccess(event.target.checked)} + className={classNames(classes['switch'], classes['default-switch'])} + /> + +
+ + Default role on join: + handleDefaultToggleAccess(event.target.checked)} + className={classNames(classes['switch'], classes['default-switch'])} + /> + + + + + Name + Role + + + + {Object.entries(users).map(([userId, user], index) => ( + + +
+
{user.name}
+ + + {user.role === CollabEditingAccess.OWNER ? ( + 'Admin' + ) : ( + handleToggleAccess(event.target.checked, userId)} + className={classes['switch']} + /> + )} + + + ))} + + + + ); +} + +type Props = { + users: Record; + playgroundCode: string; + readOnly: boolean; + workspaceLocation: SideContentLocation; +}; + +const SideContentSessionManagement: React.FC = ({ + users, + playgroundCode, + readOnly, + workspaceLocation +}) => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(beginAlertSideContent(SideContentType.sessionManagement, workspaceLocation)); + }, [dispatch, workspaceLocation, users]); + + if (Object.values(users).length === 0) return; + const myself = Object.values(users)[0]; + + return ( +
+ + This is the session management tab. Add users by sharing the session code. If you are the + owner of this session, you can manage users' access levels from the table below. + +
+ + Session code: + + showSuccessMessage('Session url copied: ' + getPlaygroundSessionUrl(playgroundCode)) + } + > +
+ {getPlaygroundSessionUrl(playgroundCode)} + +
+
+
+
+ + Number of users in the session: {Object.entries(users).length} + +
+ {myself.role === CollabEditingAccess.OWNER ? ( + + ) : ( + + + + Name + Role + + + + {Object.values(users).map((user, index) => { + return ( + + +
+
{user.name}
+ + + {user.role === CollabEditingAccess.OWNER + ? 'Admin' + : user.role.charAt(0).toUpperCase() + user.role.slice(1)} + + + ); + })} + + + )} +
+
+ ); +}; + +export default SideContentSessionManagement; diff --git a/src/commons/sideContent/content/SideContentSubstVisualizer.tsx b/src/commons/sideContent/content/SideContentSubstVisualizer.tsx index a6c4cad72d..7de22e6ac1 100644 --- a/src/commons/sideContent/content/SideContentSubstVisualizer.tsx +++ b/src/commons/sideContent/content/SideContentSubstVisualizer.tsx @@ -94,7 +94,6 @@ type SubstVisualizerPropsAST = { }; const SideContentSubstVisualizer: React.FC = props => { - console.log(props); const [stepValue, setStepValue] = useState(1); const lastStepValue = props.content.length; const hasRunCode = lastStepValue !== 0; diff --git a/src/commons/utils/AceHelper.ts b/src/commons/utils/AceHelper.ts index 7e958f124c..e023b7ee38 100644 --- a/src/commons/utils/AceHelper.ts +++ b/src/commons/utils/AceHelper.ts @@ -2,6 +2,7 @@ import { HighlightRulesSelector, ModeSelector } from 'js-slang/dist/editors/ace/ import { Chapter, Variant } from 'js-slang/dist/types'; import { HighlightRulesSelector_native } from '../../features/fullJS/fullJSHighlight'; +import { ExternalLibraryName } from '../application/types/ExternalTypes'; import { Documentation } from '../documentation/Documentation'; /** * This _modifies global state_ and defines a new Ace mode globally, if it does not already exist. @@ -67,3 +68,62 @@ export const getModeString = (chapter: Chapter, variant: Variant, library: strin return `source${chapter}${variant}${library}`; } }; + +export const parseModeString = ( + modeString: string +): { chapter: Chapter; variant: Variant; library: ExternalLibraryName } => { + switch (modeString) { + case 'html': + return { chapter: Chapter.HTML, variant: Variant.DEFAULT, library: ExternalLibraryName.NONE }; + case 'typescript': + return { + chapter: Chapter.FULL_TS, + variant: Variant.DEFAULT, + library: ExternalLibraryName.NONE + }; + case 'python': + return { + chapter: Chapter.PYTHON_1, + variant: Variant.DEFAULT, + library: ExternalLibraryName.NONE + }; + case 'scheme': + return { + chapter: Chapter.FULL_SCHEME, + variant: Variant.EXPLICIT_CONTROL, + library: ExternalLibraryName.NONE + }; + case 'java': + return { + chapter: Chapter.FULL_JAVA, + variant: Variant.DEFAULT, + library: ExternalLibraryName.NONE + }; + case 'c_cpp': + return { + chapter: Chapter.FULL_C, + variant: Variant.DEFAULT, + library: ExternalLibraryName.NONE + }; + default: + const matches = modeString.match(/source(-?\d+)([a-z\-]+)([A-Z]+)/); + if (!matches) { + throw new Error('Invalid modeString'); + } + const [_, chapter, variant, externalLibraryName] = matches; + return { + chapter: + chapter === '1' + ? Chapter.SOURCE_1 + : chapter === '2' + ? Chapter.SOURCE_2 + : chapter === '3' + ? Chapter.SOURCE_3 + : Chapter.SOURCE_4, + variant: Variant[variant as keyof typeof Variant] || Variant.DEFAULT, + library: + ExternalLibraryName[externalLibraryName as keyof typeof ExternalLibraryName] || + ExternalLibraryName.NONE + }; + } +}; diff --git a/src/commons/utils/Constants.ts b/src/commons/utils/Constants.ts index e5cc2c8c22..0ee626e184 100644 --- a/src/commons/utils/Constants.ts +++ b/src/commons/utils/Constants.ts @@ -42,6 +42,7 @@ const sicpBackendUrl = process.env.REACT_APP_SICPJS_BACKEND_URL || 'https://sicp.sourceacademy.org/'; const javaPackagesUrl = 'https://source-academy.github.io/modules/java/java-packages/src/'; const workspaceSettingsLocalStorageKey = 'workspace-settings'; +const collabSessionIdLocalStorageKey = 'playground-session-id'; // For achievements feature (CA - Continual Assessment) // TODO: remove dependency of the ca levels on the env file @@ -183,6 +184,7 @@ const Constants = { sicpBackendUrl, javaPackagesUrl, workspaceSettingsLocalStorageKey, + collabSessionIdLocalStorageKey, caFulfillmentLevel, featureFlags }; diff --git a/src/commons/utils/StoriesHelper.ts b/src/commons/utils/StoriesHelper.ts index 3f74db64f1..4e78351793 100644 --- a/src/commons/utils/StoriesHelper.ts +++ b/src/commons/utils/StoriesHelper.ts @@ -1,7 +1,11 @@ import { h } from 'hastscript'; +import { Nodes as MdastNodes } from 'mdast'; import { fromMarkdown } from 'mdast-util-from-markdown'; -import { defaultHandlers, toHast } from 'mdast-util-to-hast'; -import { MdastNodes, Options as MdastToHastConverterOptions } from 'mdast-util-to-hast/lib'; +import { + defaultHandlers, + Options as MdastToHastConverterOptions, + toHast +} from 'mdast-util-to-hast'; import React from 'react'; import * as runtime from 'react/jsx-runtime'; import { IEditorProps } from 'react-ace'; diff --git a/src/commons/workspace/WorkspaceReducer.ts b/src/commons/workspace/WorkspaceReducer.ts index 7e4f241ea9..59fef33b93 100644 --- a/src/commons/workspace/WorkspaceReducer.ts +++ b/src/commons/workspace/WorkspaceReducer.ts @@ -1,4 +1,5 @@ import { createReducer, type Reducer } from '@reduxjs/toolkit'; +import { castDraft } from 'immer'; import { SourcecastReducer } from '../../features/sourceRecorder/sourcecast/SourcecastReducer'; import { SourcereelReducer } from '../../features/sourceRecorder/sourcereel/SourcereelReducer'; @@ -15,7 +16,8 @@ import { import { setEditorSessionId, setSessionDetails, - setSharedbConnected + setSharedbConnected, + setUpdateUserRoleCallback } from '../collabEditing/CollabEditingActions'; import type { SourceActionType } from '../utils/ActionsHelper'; import { createContext } from '../utils/JsSlangHelper'; @@ -138,10 +140,10 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { .addCase(logOut, (state, action) => { // Preserve the playground workspace even after log out const playgroundWorkspace = state.playground; - return { + return castDraft({ ...defaultWorkspaceManager, playground: playgroundWorkspace - }; + }); }) .addCase(WorkspaceActions.enableTokenCounter, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); @@ -308,7 +310,16 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { }) .addCase(setSessionDetails, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); - state[workspaceLocation].sessionDetails = action.payload.sessionDetails; + return { + ...state, + [workspaceLocation]: { + ...state[workspaceLocation], + sessionDetails: { + ...state[workspaceLocation].sessionDetails, + ...action.payload.sessionDetails + } + } + }; }) .addCase(WorkspaceActions.setIsEditorReadonly, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); @@ -378,5 +389,9 @@ const newWorkspaceReducer = createReducer(defaultWorkspaceManager, builder => { .addCase(WorkspaceActions.updateLastDebuggerResult, (state, action) => { const workspaceLocation = getWorkspaceLocation(action); state[workspaceLocation].lastDebuggerResult = action.payload.lastDebuggerResult; + }) + .addCase(setUpdateUserRoleCallback, (state, action) => { + const workspaceLocation = getWorkspaceLocation(action); + state[workspaceLocation].updateUserRoleCallback = action.payload.updateUserRoleCallback; }); }); diff --git a/src/commons/workspace/WorkspaceTypes.ts b/src/commons/workspace/WorkspaceTypes.ts index ea9f137a9e..5511dec4d2 100644 --- a/src/commons/workspace/WorkspaceTypes.ts +++ b/src/commons/workspace/WorkspaceTypes.ts @@ -1,3 +1,4 @@ +import type { CollabEditingAccess } from '@sourceacademy/sharedb-ace/types'; import type { Context } from 'js-slang'; import type { @@ -85,7 +86,7 @@ export type WorkspaceState = { readonly programPrependValue: string; readonly programPostpendValue: string; readonly editorSessionId: string; - readonly sessionDetails: { docId: string; readOnly: boolean } | null; + readonly sessionDetails: { docId: string; readOnly: boolean; owner: boolean } | null; readonly editorTestcases: Testcase[]; readonly execTime: number; readonly isRunning: boolean; @@ -106,6 +107,7 @@ export type WorkspaceState = { readonly debuggerContext: DebuggerContext; readonly lastDebuggerResult: any; readonly files: UploadResult; + readonly updateUserRoleCallback: (id: string, newRole: CollabEditingAccess) => void; }; type ReplHistory = { diff --git a/src/commons/workspace/sharedb-ace.d.ts b/src/commons/workspace/sharedb-ace.d.ts deleted file mode 100644 index cc0d68acbf..0000000000 --- a/src/commons/workspace/sharedb-ace.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@sourceacademy/sharedb-ace'; diff --git a/src/features/sourceRecorder/SourceRecorderTypes.ts b/src/features/sourceRecorder/SourceRecorderTypes.ts index 74030ea2cd..016c501746 100644 --- a/src/features/sourceRecorder/SourceRecorderTypes.ts +++ b/src/features/sourceRecorder/SourceRecorderTypes.ts @@ -1,4 +1,4 @@ -import { Ace } from 'ace-builds/ace'; +import { Ace } from 'ace-builds'; import { Chapter } from 'js-slang/dist/types'; import { ExternalLibraryName } from '../../commons/application/types/ExternalTypes'; diff --git a/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts b/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts index f47bf97f78..1ebd9eb34e 100644 --- a/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts +++ b/src/features/sourceRecorder/sourcecast/SourcecastReducer.ts @@ -1,4 +1,5 @@ import { createReducer } from '@reduxjs/toolkit'; +import { castDraft } from 'immer'; import { defaultWorkspaceManager } from 'src/commons/application/ApplicationTypes'; import * as SourceRecorderActions from '../SourceRecorderActions'; @@ -11,23 +12,23 @@ export const SourcecastReducer = createReducer(defaultWorkspaceManager.sourcecas state.description = action.payload.description; state.uid = action.payload.uid; state.audioUrl = action.payload.audioUrl; - state.playbackData = action.payload.playbackData; + state.playbackData = castDraft(action.payload.playbackData); }) .addCase(SourceRecorderActions.setCurrentPlayerTime, (state, action) => { state.currentPlayerTime = action.payload.playerTime; }) .addCase(SourceRecorderActions.setCodeDeltasToApply, (state, action) => { - state.codeDeltasToApply = action.payload.deltas; + state.codeDeltasToApply = castDraft(action.payload.deltas); }) .addCase(SourceRecorderActions.setInputToApply, (state, action) => { - state.inputToApply = action.payload.inputToApply; + state.inputToApply = castDraft(action.payload.inputToApply); }) .addCase(SourceRecorderActions.setSourcecastData, (state, action) => { state.title = action.payload.title; state.description = action.payload.description; state.uid = action.payload.uid; state.audioUrl = action.payload.audioUrl; - state.playbackData = action.payload.playbackData; + state.playbackData = castDraft(action.payload.playbackData); }) .addCase(SourceRecorderActions.setSourcecastDuration, (state, action) => { state.playbackDuration = action.payload.duration; diff --git a/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts b/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts index b3637def5e..b9ecafb672 100644 --- a/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts +++ b/src/features/sourceRecorder/sourcereel/SourcereelReducer.ts @@ -1,4 +1,5 @@ import { createReducer } from '@reduxjs/toolkit'; +import { castDraft } from 'immer'; import { defaultWorkspaceManager } from 'src/commons/application/ApplicationTypes'; import { RecordingStatus } from '../SourceRecorderTypes'; @@ -11,10 +12,10 @@ export const SourcereelReducer = createReducer(defaultWorkspaceManager.sourceree state.playbackData.inputs = []; }) .addCase(SourcereelActions.recordInput, (state, action) => { - state.playbackData.inputs.push(action.payload.input); + state.playbackData.inputs.push(castDraft(action.payload.input)); }) .addCase(SourcereelActions.resetInputs, (state, action) => { - state.playbackData.inputs = action.payload.inputs; + state.playbackData.inputs = castDraft(action.payload.inputs); }) .addCase(SourcereelActions.timerPause, (state, action) => { state.recordingStatus = RecordingStatus.paused; diff --git a/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx index ef091afd65..d00ef3cd92 100644 --- a/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/assessmentConfigPanel/AssessmentConfigPanel.tsx @@ -267,7 +267,8 @@ const AssessmentConfigPanel: WithImperativeApi< setHasVotingFeatures, setHoursBeforeDecay, setIsGradingAutoPublished, - setIsManuallyGraded + setIsManuallyGraded, + setIsMinigame ] ); diff --git a/src/pages/playground/Playground.tsx b/src/pages/playground/Playground.tsx index c85310f359..b664d3b24d 100644 --- a/src/pages/playground/Playground.tsx +++ b/src/pages/playground/Playground.tsx @@ -2,6 +2,7 @@ import { Classes } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { type HotkeyItem, useHotkeys } from '@mantine/hooks'; import type { AnyAction, Dispatch } from '@reduxjs/toolkit'; +import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types'; import { Ace, Range } from 'ace-builds'; import type { FSModule } from 'browserfs/dist/node/core/FS'; import classNames from 'classnames'; @@ -91,6 +92,7 @@ import { desktopOnlyTabIds, makeIntroductionTabFrom, makeRemoteExecutionTabFrom, + makeSessionManagementTabFrom, makeSubstVisualizerTabFrom, mobileOnlyTabIds } from './PlaygroundTabs'; @@ -283,6 +285,7 @@ const Playground: React.FC = props => { chapter: playgroundSourceChapter }) ); + const [users, setUsers] = useState>({}); // Playground hotkeys const [isGreen, setIsGreen] = useState(false); @@ -297,6 +300,10 @@ const Playground: React.FC = props => { [deviceSecret] ); + const sessionManagementTab: SideContentTab = useMemo(() => { + return makeSessionManagementTabFrom(users, editorSessionId, sessionDetails?.readOnly || false); + }, [users, editorSessionId, sessionDetails?.readOnly]); + const usingRemoteExecution = useTypedSelector(state => !!state.session.remoteExecutionSession) && !isSicpEditor; // this is still used by remote execution (EV3) @@ -663,25 +670,36 @@ const Playground: React.FC = props => { [store, workspaceLocation] ); + const handleSetEditorSessionId = useCallback( + (id: string) => dispatch(setEditorSessionId(workspaceLocation, id)), + [dispatch, workspaceLocation] + ); + + const handleSetSessionDetails = useCallback( + (details: { docId: string; readOnly: boolean; owner: boolean } | null) => + dispatch(setSessionDetails(workspaceLocation, details)), + [dispatch, workspaceLocation] + ); + const sessionButtons = useMemo( () => ( dispatch(setEditorSessionId(workspaceLocation, id))} - handleSetSessionDetails={details => dispatch(setSessionDetails(workspaceLocation, details))} + handleSetEditorSessionId={handleSetEditorSessionId} + handleSetSessionDetails={handleSetSessionDetails} sharedbConnected={sharedbConnected} key="session" /> ), [ - dispatch, - getEditorValue, isFolderModeEnabled, editorSessionId, - sharedbConnected, - workspaceLocation + getEditorValue, + handleSetEditorSessionId, + handleSetSessionDetails, + sharedbConnected ] ); @@ -775,21 +793,26 @@ const Playground: React.FC = props => { if (!isSicpEditor && !Constants.playgroundOnly) { tabs.push(remoteExecutionTab); + if (editorSessionId !== '') { + tabs.push(sessionManagementTab); + } } return tabs; }, [ playgroundIntroductionTab, languageConfig.chapter, - output, usingRemoteExecution, isSicpEditor, - dispatch, + output, workspaceLocation, + dispatch, shouldShowDataVisualizer, shouldShowCseMachine, shouldShowSubstVisualizer, - remoteExecutionTab + remoteExecutionTab, + editorSessionId, + sessionManagementTab ]); // Remove Intro and Remote Execution tabs for mobile @@ -933,7 +956,9 @@ const Playground: React.FC = props => { externalLibraryName, sourceVariant: languageConfig.variant, handleEditorValueChange: onEditorValueChange, - handleEditorUpdateBreakpoints: handleEditorUpdateBreakpoints + handleEditorUpdateBreakpoints: handleEditorUpdateBreakpoints, + setUsers, + updateLanguageCallback: chapterSelectHandler }; const replHandlers = useMemo(() => { diff --git a/src/pages/playground/PlaygroundTabs.tsx b/src/pages/playground/PlaygroundTabs.tsx index 95aac5c5da..518688df62 100644 --- a/src/pages/playground/PlaygroundTabs.tsx +++ b/src/pages/playground/PlaygroundTabs.tsx @@ -1,7 +1,9 @@ import { IconNames } from '@blueprintjs/icons'; +import type { SharedbAceUser } from '@sourceacademy/sharedb-ace/types'; import { InterpreterOutput } from 'src/commons/application/ApplicationTypes'; import Markdown from 'src/commons/Markdown'; import SideContentRemoteExecution from 'src/commons/sideContent/content/remoteExecution/SideContentRemoteExecution'; +import SideContentSessionManagement from 'src/commons/sideContent/content/SideContentSessionManagement'; import SideContentSubstVisualizer from 'src/commons/sideContent/content/SideContentSubstVisualizer'; import { SideContentLocation, @@ -22,6 +24,24 @@ export const makeIntroductionTabFrom = (content: string): SideContentTab => ({ id: SideContentType.introduction }); +export const makeSessionManagementTabFrom = ( + users: Record, + playgroundCode: string, + readOnly: boolean +): SideContentTab => ({ + label: 'Session Management', + iconName: IconNames.PEOPLE, + body: ( + + ), + id: SideContentType.sessionManagement +}); + export const makeRemoteExecutionTabFrom = ( deviceSecret: string | undefined, callback: React.Dispatch> diff --git a/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx b/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx index 0c499b7936..5f369b132b 100644 --- a/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx +++ b/src/pages/sicp/subcomponents/__tests__/SicpExercise.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { renderTreeJson } from 'src/commons/utils/TestUtils'; import SicpExercise, { noSolutionPlaceholder } from '../SicpExercise'; diff --git a/src/routes/routerConfig.tsx b/src/routes/routerConfig.tsx index 4c13425b43..97582096ff 100644 --- a/src/routes/routerConfig.tsx +++ b/src/routes/routerConfig.tsx @@ -138,7 +138,7 @@ export const getFullAcademyRouterConfig = ({ { path: 'welcome', lazy: Welcome, loader: welcomeLoader }, { path: 'courses', element: }, ensureUserAndRole({ path: 'courses/:courseId/*', lazy: Academy, children: academyRoutes }), - ensureUserAndRole({ path: 'playground', lazy: Playground }), + ensureUserAndRole({ path: 'playground/:playgroundCode?', lazy: Playground }), { path: 'mission-control/:assessmentId?/:questionId?', lazy: MissionControl }, ensureUserAndRole({ path: 'courses/:courseId/stories/new', lazy: EditStory }), ensureUserAndRole({ path: 'courses/:courseId/stories/view/:id', lazy: ViewStory }), diff --git a/src/styles/SideContentSessionManagement.module.scss b/src/styles/SideContentSessionManagement.module.scss new file mode 100644 index 0000000000..bf443ee438 --- /dev/null +++ b/src/styles/SideContentSessionManagement.module.scss @@ -0,0 +1,69 @@ +@import '_global'; +@import '@blueprintjs/core/lib/scss/variables'; + +.span { + display: inline-flex; + margin-block: 0.5rem; +} + +.left-cell { + min-height: 40px; + display: flex; + align-items: center; +} + +.right-cell { + vertical-align: middle !important; +} + +.switch { + margin-bottom: 0px; +} + +.default-switch { + display: inline-block; + margin-left: 5px; +} + +.table { + padding-inline: 0px !important; +} + +.table-container { + padding-top: 1rem; + padding-inline: 1rem; +} + +.session-code { + color: $blue4; + margin-left: 5px; + display: flex; + column-gap: 5px; +} + +.session-code:hover { + cursor: pointer; + color: $blue5; +} + +.user-icon { + margin-right: 5px; +} + +.side-content-tab-alert { + -webkit-animation: alert 1s infinite; + -moz-animation: alert 1s infinite; + -o-animation: alert 1s infinite; + animation: alert 1s infinite; +} + +@keyframes alert { + 0%, + 50% { + background-color: rgba(200, 100, 50, 0.5); + } + 51%, + 100% { + background-image: rgba(138, 155, 168, 0.3); + } +} diff --git a/src/styles/_workspace.scss b/src/styles/_workspace.scss index df4cbddc40..08c7e4da9d 100755 --- a/src/styles/_workspace.scss +++ b/src/styles/_workspace.scss @@ -745,6 +745,34 @@ $code-color-notification: #f9f0d7; } } + .session-management-table { + table { + width: 100%; + padding-inline: 0.5em; + border-spacing: 0 0.5em; + + th:last-child, + td:last-child { + width: auto; + } + + td:first-child { + display: flex; + flex-direction: row; + gap: 5px; + width: 100%; + } + + td { + div:first-child { + width: 15px; + height: 15px; + border-radius: 50%; + } + } + } + } + .react-mde { /* Colour the borders */ border-color: #1b2530; @@ -913,6 +941,8 @@ $code-color-notification: #f9f0d7; background: $cadet-color-3; color: rgb(128, 145, 160); } + display: inline-block; + flex: 1; } .react-ace-green { @@ -922,6 +952,22 @@ $code-color-notification: #f9f0d7; background: $dark-green; color: rgb(128, 145, 160); } + display: inline-block; + flex: 1; + } + + #ace-radar-view { + display: inline-block; + max-width: 2%; + background: #2f3129; + } + + .ace-radar-view-cursor-indicator { + display: none; + } + + .ace-radar-view-wrapper { + width: 1px; } .Autograder, diff --git a/tsconfig.json b/tsconfig.json index c988d3f032..2d499116a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "jsx": "react-jsx", "strict": true, "useUnknownInCatchVariables": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "rootDir": "src", "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, diff --git a/yarn.lock b/yarn.lock index ffff2f0728..59a5f3c93e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -103,20 +103,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.7.2": - version: 7.27.5 - resolution: "@babel/generator@npm:7.27.5" - dependencies: - "@babel/parser": "npm:^7.27.5" - "@babel/types": "npm:^7.27.3" - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - jsesc: "npm:^3.0.2" - checksum: 10c0/8f649ef4cd81765c832bb11de4d6064b035ffebdecde668ba7abee68a7b0bce5c9feabb5dc5bb8aeba5bd9e5c2afa3899d852d2bd9ca77a711ba8c8379f416f0 - languageName: node - linkType: hard - -"@babel/generator@npm:^7.28.0": +"@babel/generator@npm:^7.28.0, @babel/generator@npm:^7.7.2": version: 7.28.0 resolution: "@babel/generator@npm:7.28.0" dependencies: @@ -330,18 +317,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.19.4, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.27.7": - version: 7.27.7 - resolution: "@babel/parser@npm:7.27.7" - dependencies: - "@babel/types": "npm:^7.27.7" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/f6202faeb873f0b3083022e50a5046fe07266d337c0a3bd80a491f8435ba6d9e383d49725e3dcd666b3b52c0dccb4e0f1f1004915762345f7eeed5ba54ea9fd2 - languageName: node - linkType: hard - -"@babel/parser@npm:^7.28.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.19.4, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.0": version: 7.28.0 resolution: "@babel/parser@npm:7.28.0" dependencies: @@ -1436,22 +1412,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3": - version: 7.27.7 - resolution: "@babel/traverse@npm:7.27.7" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.27.5" - "@babel/parser": "npm:^7.27.7" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.27.7" - debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 10c0/941fecd0248546f059d58230590a2765d128ef072c8521c9e0bcf6037abf28a0ea4736003d0d695513128d07fe00a7bc57acaada2ed905941d44619b9f49cf0c - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.0": +"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3, @babel/traverse@npm:^7.28.0": version: 7.28.0 resolution: "@babel/traverse@npm:7.28.0" dependencies: @@ -1466,17 +1427,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.0, @babel/types@npm:^7.21.3, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.27.7, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": - version: 7.27.7 - resolution: "@babel/types@npm:7.27.7" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/1d1dcb5fa7cfba2b4034a3ab99ba17049bfc4af9e170935575246cdb1cee68b04329a0111506d9ae83fb917c47dbd4394a6db5e32fbd041b7834ffbb17ca086b - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.0": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.0, @babel/types@npm:^7.21.3, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.0, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.28.1 resolution: "@babel/types@npm:7.28.1" dependencies: @@ -2351,7 +2302,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.12": +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.12 resolution: "@jridgewell/gen-mapping@npm:0.3.12" dependencies: @@ -2361,17 +2312,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.5 - resolution: "@jridgewell/gen-mapping@npm:0.3.5" - dependencies: - "@jridgewell/set-array": "npm:^1.2.1" - "@jridgewell/sourcemap-codec": "npm:^1.4.10" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb - languageName: node - linkType: hard - "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -2379,7 +2319,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/set-array@npm:^1.0.0, @jridgewell/set-array@npm:^1.2.1": +"@jridgewell/set-array@npm:^1.0.0": version: 1.2.1 resolution: "@jridgewell/set-array@npm:1.2.1" checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 @@ -2396,31 +2336,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14": - version: 1.4.15 - resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" - checksum: 10c0/0c6b5ae663087558039052a626d2d7ed5208da36cfd707dcc5cea4a07cfc918248403dcb5989a8f7afaf245ce0573b7cc6fd94c4a30453bd10e44d9363940ba5 - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.5.0": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.4 resolution: "@jridgewell/sourcemap-codec@npm:1.5.4" checksum: 10c0/c5aab3e6362a8dd94ad80ab90845730c825fc4c8d9cf07ebca7a2eb8a832d155d62558800fc41d42785f989ddbb21db6df004d1786e8ecb65e428ab8dff71309 languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.9": - version: 0.3.25 - resolution: "@jridgewell/trace-mapping@npm:0.3.25" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28, @jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.29 resolution: "@jridgewell/trace-mapping@npm:0.3.29" dependencies: @@ -3400,15 +3323,16 @@ __metadata: languageName: node linkType: hard -"@sourceacademy/sharedb-ace@npm:^2.0.3": - version: 2.0.3 - resolution: "@sourceacademy/sharedb-ace@npm:2.0.3" +"@sourceacademy/sharedb-ace@npm:^2.1.0": + version: 2.1.0 + resolution: "@sourceacademy/sharedb-ace@npm:2.1.0" dependencies: + "@convergencelabs/ace-collab-ext": "npm:^0.6.0" event-emitter-es6: "npm:^1.1.5" logdown: "npm:^3.3.1" - reconnecting-websocket: "npm:^4.4.0" - sharedb: "npm:^1.4.1" - checksum: 10c0/68c829c0f63a3d902390183906bbd67b3646dde7a6cb46bd3cc4f6e9f78ff5ae8990460bd9f9da45ee714c033f647c56602b1c9bf1e717af121297f3ec873278 + partysocket: "npm:^1.0.3" + sharedb: "npm:^5.1.1" + checksum: 10c0/c66efa6085f9ec8639164a5a291dd929cb84bf4d44dd72fe534099fadd68aa126b59e95d451585c35d33c14d55c3297649f3720522d4d35f1fe29bbeb03758ae languageName: node linkType: hard @@ -3826,7 +3750,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:^14.4.3": +"@testing-library/user-event@npm:^14.6.0": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" peerDependencies: @@ -4426,7 +4350,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33": +"@types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" dependencies: @@ -4435,15 +4359,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.8": - version: 17.0.32 - resolution: "@types/yargs@npm:17.0.32" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10c0/2095e8aad8a4e66b86147415364266b8d607a3b95b4239623423efd7e29df93ba81bb862784a6e08664f645cc1981b25fd598f532019174cd3e5e1e689e1cccf - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.36.0": version: 8.36.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.36.0" @@ -4644,10 +4559,10 @@ __metadata: languageName: node linkType: hard -"ace-builds@npm:^1.36.3, ace-builds@npm:^1.4.12": - version: 1.36.3 - resolution: "ace-builds@npm:1.36.3" - checksum: 10c0/7d7a35f393cd70555d559afcc0521dcda7fe78585cdfd41525a7bd9d59115f8b9f40ac3ff597907faf2ac2f71e1174c6797df698df48a8041c6b6433dba316b5 +"ace-builds@npm:^1.36.3, ace-builds@npm:^1.4.12, ace-builds@npm:^1.42.1": + version: 1.43.0 + resolution: "ace-builds@npm:1.43.0" + checksum: 10c0/df969c3d706272cc23fdb59b47b7ef04ee01cd6af11f90ab4a2dc244064a4120465d6a9dc4fd247fef77f9634f2b8dc0a81fae07fcf751b56b069013432e673b languageName: node linkType: hard @@ -4988,7 +4903,7 @@ __metadata: languageName: node linkType: hard -"arraydiff@npm:^0.1.1": +"arraydiff@npm:^0.1.3": version: 0.1.3 resolution: "arraydiff@npm:0.1.3" checksum: 10c0/a376b8f6c22cd502b810a265c6043888ddbe55f367d28d42e5b1e10adf98290cd0308e4930429a30624715589d6801764b2933cf34af4aba333982b89f7e1f9c @@ -5044,7 +4959,7 @@ __metadata: languageName: node linkType: hard -"async@npm:^2.1.4, async@npm:^2.6.3": +"async@npm:^2.1.4": version: 2.6.4 resolution: "async@npm:2.6.4" dependencies: @@ -5053,7 +4968,7 @@ __metadata: languageName: node linkType: hard -"async@npm:^3.2.3": +"async@npm:^3.2.3, async@npm:^3.2.4": version: 3.2.6 resolution: "async@npm:3.2.6" checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 @@ -7385,6 +7300,13 @@ __metadata: languageName: node linkType: hard +"event-target-polyfill@npm:^0.0.4": + version: 0.0.4 + resolution: "event-target-polyfill@npm:0.0.4" + checksum: 10c0/7052b838df6509e8290f110daf94604dd05828834db4ad6fe4a12abc693da1a3274100cfd4ff3e47a941d5aa3fb7cdb8164d85402b93d85340bb3adc596d8732 + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -7496,13 +7418,6 @@ __metadata: languageName: node linkType: hard -"fast-deep-equal@npm:^2.0.1": - version: 2.0.1 - resolution: "fast-deep-equal@npm:2.0.1" - checksum: 10c0/1602e0d6ed63493c865cc6b03f9070d6d3926e8cd086a123060b58f80a295f3f08b1ecfb479ae7c45b7fd45535202aea7cf5b49bc31bffb81c20b1502300be84 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -7760,7 +7675,7 @@ __metadata: "@rsbuild/plugin-svgr": "npm:^1.2.0" "@sentry/browser": "npm:^8.33.0" "@sourceacademy/c-slang": "npm:^1.0.21" - "@sourceacademy/sharedb-ace": "npm:^2.0.3" + "@sourceacademy/sharedb-ace": "npm:^2.1.0" "@sourceacademy/sling-client": "npm:^0.1.0" "@svgr/webpack": "npm:^8.0.0" "@swc/core": "npm:^1.11.22" @@ -7770,7 +7685,7 @@ __metadata: "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" - "@testing-library/user-event": "npm:^14.4.3" + "@testing-library/user-event": "npm:^14.6.0" "@tremor/react": "npm:^1.8.2" "@types/estree": "npm:^1.0.5" "@types/gapi": "npm:^0.0.47" @@ -7792,7 +7707,7 @@ __metadata: "@types/redux-mock-store": "npm:^1.0.3" "@types/showdown": "npm:^2.0.1" "@types/xml2js": "npm:^0.4.11" - ace-builds: "npm:^1.36.3" + ace-builds: "npm:^1.42.1" acorn: "npm:^8.9.0" ag-grid-community: "npm:^32.3.1" ag-grid-react: "npm:^32.3.1" @@ -7821,6 +7736,7 @@ __metadata: i18next: "npm:^25.0.0" i18next-browser-languagedetector: "npm:^8.0.0" identity-obj-proxy: "npm:^3.0.0" + immer: "npm:^10.1.1" java-slang: "npm:^1.0.13" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -8605,6 +8521,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653 + languageName: node + linkType: hard + "immer@npm:^9.0.21": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -11224,7 +11147,7 @@ __metadata: languageName: node linkType: hard -"ot-json0@npm:^1.0.1": +"ot-json0@npm:^1.1.0": version: 1.1.0 resolution: "ot-json0@npm:1.1.0" checksum: 10c0/b553940a90fc0b46f246a832b18cecb37089edd44dd6f6eec84a0753c54c1cf739fe19ef550efa2c0f15f7245eadb3226d7587416315413819512d4c1f7c02b4 @@ -11381,6 +11304,15 @@ __metadata: languageName: node linkType: hard +"partysocket@npm:^1.0.3": + version: 1.1.4 + resolution: "partysocket@npm:1.1.4" + dependencies: + event-target-polyfill: "npm:^0.0.4" + checksum: 10c0/d859bcaef5f5e8be0f383140a896a41e78c95fc2166002d2a60f09168c720d78864e8f8e3251ed5146d46bf9487102dd9c81d34893c811bfc8ea64f7236f2fed + languageName: node + linkType: hard + "pascal-case@npm:^3.1.2": version: 3.1.2 resolution: "pascal-case@npm:3.1.2" @@ -12092,10 +12024,10 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0": - version: 18.2.0 - resolution: "react-is@npm:18.2.0" - checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 languageName: node linkType: hard @@ -12106,13 +12038,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - "react-konva@npm:^18.2.10": version: 18.2.12 resolution: "react-konva@npm:18.2.12" @@ -12538,13 +12463,6 @@ __metadata: languageName: node linkType: hard -"reconnecting-websocket@npm:^4.4.0": - version: 4.4.0 - resolution: "reconnecting-websocket@npm:4.4.0" - checksum: 10c0/0155223200882e123bc884eb5935bdff7ee4d2998eee578c23bba6a6fec63b68c22ccaf9ff4bdcd05284568d89f02e6a664cc40daf108872f820197848b09579 - languageName: node - linkType: hard - "redent@npm:^3.0.0": version: 3.0.0 resolution: "redent@npm:3.0.0" @@ -13358,16 +13276,16 @@ __metadata: languageName: node linkType: hard -"sharedb@npm:^1.4.1": - version: 1.9.2 - resolution: "sharedb@npm:1.9.2" +"sharedb@npm:^5.1.1": + version: 5.2.2 + resolution: "sharedb@npm:5.2.2" dependencies: - arraydiff: "npm:^0.1.1" - async: "npm:^2.6.3" - fast-deep-equal: "npm:^2.0.1" + arraydiff: "npm:^0.1.3" + async: "npm:^3.2.4" + fast-deep-equal: "npm:^3.1.3" hat: "npm:0.0.3" - ot-json0: "npm:^1.0.1" - checksum: 10c0/e9075b66f95175330bcb0753544ed3a23820a4b52f76994266fe56d5d137827207af6e8fb3dcfc86b3becf3ed7bf8d52dbe6485f95f3879e16d193bf27011612 + ot-json0: "npm:^1.1.0" + checksum: 10c0/9e8f6726a4f31fa1df3dd4b7019de088247e1584692f61c7ca20fc198c1b8d2540eff57810bfef6ba7c879ce99a9cbc483318262cf591b5e2c651481f9991fdc languageName: node linkType: hard