diff --git a/apps/collaboration/app.ts b/apps/collaboration/app.ts new file mode 100644 index 00000000..d5c8c110 --- /dev/null +++ b/apps/collaboration/app.ts @@ -0,0 +1,9 @@ +import { Hocuspocus } from "@hocuspocus/server"; + +// Configure the server … +const server = new Hocuspocus({ + port: 1998, +}); + +// … and run it! +server.listen(); diff --git a/apps/collaboration/package.json b/apps/collaboration/package.json new file mode 100644 index 00000000..fdd9fa10 --- /dev/null +++ b/apps/collaboration/package.json @@ -0,0 +1,19 @@ +{ + "name": "collaboration", + "version": "1.0.0", + "description": "", + "main": "app.ts", + "scripts": { + "start": "bun app.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@hocuspocus/server": "^2.11.3", + "bun": "^1.0.36", + "typescript": "5.4.3", + "y-protocols": "^1.0.6", + "yjs": "^13.6.14" + } +} diff --git a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx index 27c85c02..8e4c4cf8 100644 --- a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx +++ b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx @@ -49,7 +49,7 @@ const EditActivity = async (params: any) => { access_token ? access_token : null ) const org = await getOrganizationContextInfoWithId(courseInfo.org_id, { - revalidate: 1800, + revalidate: 180, tags: ['organizations'], }) diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 75952981..e5dbf880 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -9,7 +9,6 @@ import ImageBlock from '@components/Objects/Editor/Extensions/Image/ImageBlock' import VideoBlock from '@components/Objects/Editor/Extensions/Video/VideoBlock' import MathEquationBlock from '@components/Objects/Editor/Extensions/MathEquation/MathEquationBlock' import PDFBlock from '@components/Objects/Editor/Extensions/PDF/PDFBlock' -import { OrderedList } from '@tiptap/extension-ordered-list' import QuizBlock from '@components/Objects/Editor/Extensions/Quiz/QuizBlock' // Lowlight @@ -83,7 +82,6 @@ function Canva(props: Editor) { controls: true, modestBranding: true, }), - OrderedList.configure(), CodeBlockLowlight.configure({ lowlight, }), diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index b904fe00..fd8339d7 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -15,7 +15,7 @@ import { useAIEditorDispatch, } from '@components/Contexts/AI/AIEditorContext' -// extensions +// Extensions import InfoCallout from './Extensions/Callout/Info/InfoCallout' import WarningCallout from './Extensions/Callout/Warning/WarningCallout' import ImageBlock from './Extensions/Image/ImageBlock' @@ -28,7 +28,7 @@ import QuizBlock from './Extensions/Quiz/QuizBlock' import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import Link from 'next/link' import { getCourseThumbnailMediaDirectory } from '@services/media/media' -import { OrderedList } from '@tiptap/extension-ordered-list' + // Lowlight import { common, createLowlight } from 'lowlight' @@ -45,14 +45,19 @@ import { useSession } from '@components/Contexts/SessionContext' import AIEditorToolkit from './AI/AIEditorToolkit' import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures' import UserAvatar from '../UserAvatar' +import randomColor from 'randomcolor' +import Collaboration from '@tiptap/extension-collaboration' +import CollaborationCursor from '@tiptap/extension-collaboration-cursor' interface Editor { content: string - ydoc: any - provider: any activity: any course: any org: any + session: any + ydoc: any + hocuspocusProvider: any, + isCollabEnabledOnThisOrg: boolean setContent: (content: string) => void } @@ -63,10 +68,12 @@ function Editor(props: Editor) { const is_ai_feature_enabled = useGetAIFeatures({ feature: 'editor' }) const [isButtonAvailable, setIsButtonAvailable] = React.useState(false) + React.useEffect(() => { if (is_ai_feature_enabled) { setIsButtonAvailable(true) } + }, [is_ai_feature_enabled]) // remove course_ from course_uuid @@ -89,7 +96,7 @@ function Editor(props: Editor) { extensions: [ StarterKit.configure({ // The Collaboration extension comes with its own history handling - // history: false, + history: props.isCollabEnabledOnThisOrg ? false : undefined, }), InfoCallout.configure({ editable: true, @@ -121,26 +128,28 @@ function Editor(props: Editor) { controls: true, modestBranding: true, }), - OrderedList.configure(), CodeBlockLowlight.configure({ lowlight, }), - // Register the document with Tiptap - // Collaboration.configure({ - // document: props.ydoc, - // }), - // Register the collaboration cursor extension - // CollaborationCursor.configure({ - // provider: props.provider, - // user: { - // name: auth.userInfo.username, - // color: "#f783ac", - // }, - // }), + // Add Collaboration and CollaborationCursor only if isCollabEnabledOnThisOrg is true + ...(props.isCollabEnabledOnThisOrg ? [ + Collaboration.configure({ + document: props.hocuspocusProvider?.document, + }), + + CollaborationCursor.configure({ + provider: props.hocuspocusProvider, + user: { + name: props.session.user.first_name + ' ' + props.session.user.last_name, + color: randomColor({ luminosity: 'light' }), + }, + }), + ] : []), ], - content: props.content, + // If collab is enabled the onSynced callback ensures initial content is set only once using editor.setContent(), preventing repetitive content insertion on editor syncs. + content: props.isCollabEnabledOnThisOrg ? null : props.content, }) return ( diff --git a/apps/web/components/Objects/Editor/EditorWrapper.tsx b/apps/web/components/Objects/Editor/EditorWrapper.tsx index e7f934ba..e668f8f7 100644 --- a/apps/web/components/Objects/Editor/EditorWrapper.tsx +++ b/apps/web/components/Objects/Editor/EditorWrapper.tsx @@ -1,11 +1,17 @@ 'use client' -import { default as React } from 'react' -import * as Y from 'yjs' +import { default as React, useEffect } from 'react' import Editor from './Editor' import { updateActivity } from '@services/courses/activities' import { toast } from 'react-hot-toast' import Toast from '@components/StyledElements/Toast/Toast' import { OrgProvider } from '@components/Contexts/OrgContext' +import { useSession } from '@components/Contexts/SessionContext' + +// Collaboration +import { HocuspocusProvider } from '@hocuspocus/provider' +import * as Y from 'yjs' +import { IndexeddbPersistence } from 'y-indexeddb' +import { LEARNHOUSE_COLLABORATION_WS_URL, getCollaborationServerUrl } from '@services/config/config' interface EditorWrapperProps { content: string @@ -15,18 +21,23 @@ interface EditorWrapperProps { } function EditorWrapper(props: EditorWrapperProps): JSX.Element { - // A new Y document - const ydoc = new Y.Doc() - const [providerState, setProviderState] = React.useState({}) - const [ydocState, setYdocState] = React.useState({}) - const [isLoading, setIsLoading] = React.useState(true) + const session = useSession() as any - function createRTCProvider() { - // const provider = new WebrtcProvider(props.activity.activity_id, ydoc); - // setYdocState(ydoc); - // setProviderState(provider); - setIsLoading(false) - } + /* Collaboration Features */ + const collab = getCollaborationServerUrl() + const isCollabEnabledOnThisOrg = props.org.config.config.GeneralConfig.collaboration && collab + const doc = new Y.Doc() + + // Store the Y document in the browser + new IndexeddbPersistence(props.activity.activity_uuid, doc) + + const provider = isCollabEnabledOnThisOrg ? new HocuspocusProvider({ + url: collab, + name: props.activity.activity_uuid, + document: doc, + preserveConnection: false, + }) : null + /* Collaboration Features */ async function setContent(content: any) { let activity = props.activity @@ -39,24 +50,28 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element { }) } - if (isLoading) { - createRTCProvider() - return
Loading...
- } else { + useEffect(() => { + + } + , [session]) + + + { return ( <> - - ; + session={session} + ydoc={doc} + hocuspocusProvider={provider} + isCollabEnabledOnThisOrg={isCollabEnabledOnThisOrg} + >)} ) diff --git a/apps/web/services/config/config.ts b/apps/web/services/config/config.ts index 732cf31f..a66812d5 100644 --- a/apps/web/services/config/config.ts +++ b/apps/web/services/config/config.ts @@ -3,6 +3,8 @@ export const LEARNHOUSE_HTTP_PROTOCOL = const LEARNHOUSE_API_URL = `${process.env.NEXT_PUBLIC_LEARNHOUSE_API_URL}` export const LEARNHOUSE_BACKEND_URL = `${process.env.NEXT_PUBLIC_LEARNHOUSE_BACKEND_URL}` export const LEARNHOUSE_DOMAIN = process.env.NEXT_PUBLIC_LEARNHOUSE_DOMAIN +export const LEARNHOUSE_COLLABORATION_WS_URL = + process.env.NEXT_PUBLIC_LEARNHOUSE_COLLABORATION_WS_URL export const getAPIUrl = () => LEARNHOUSE_API_URL export const getBackendUrl = () => LEARNHOUSE_BACKEND_URL @@ -35,3 +37,7 @@ export const getOrgFromUri = () => { export const getDefaultOrg = () => { return process.env.NEXT_PUBLIC_LEARNHOUSE_DEFAULT_ORG } + +export const getCollaborationServerUrl = () => { + return `${LEARNHOUSE_COLLABORATION_WS_URL}` +}