Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9e194f0
feat(realtime-collab): land CollaborativeWrapper + codemirror / tipta…
dschmidt May 19, 2026
09815a4
refactor: simplify collaboration architecture
JammingBen Aug 6, 2026
80ba776
chore: properly integreate realtime server in monorepo
JammingBen Aug 6, 2026
7d3e18c
refactor: update types for app wrapper props
JammingBen Aug 7, 2026
45ec6a9
refactor: simplify collaboration doc strings
JammingBen Aug 7, 2026
787038c
perf: use prosemirror node for serializing
JammingBen Aug 7, 2026
7b71166
fix(collab): initial hydration for readonly users
JammingBen Aug 7, 2026
4bcdd24
fix: temporarily disable source mode when collab is setup
JammingBen Aug 7, 2026
6d6aff1
docs: add notes about the collaboration implementation
JammingBen Aug 7, 2026
d6df007
fix(realtime): prevent viewers from writing to hocuspocus
JammingBen Aug 7, 2026
625b39d
fix(collab): wrong file id for shared documents
JammingBen Aug 7, 2026
3fb7a47
fix(collab): switching files
JammingBen Aug 7, 2026
aa1257d
fix(collab): stale recovery
JammingBen Aug 7, 2026
944fa73
fix(collab): prevent losing local changes on peer save
JammingBen Aug 7, 2026
95f5063
fix(collab): keep previous 412 conflict handling for non collab
JammingBen Aug 7, 2026
97467dc
fix(collab): properly handle yjs server connection timeouts
JammingBen Aug 7, 2026
5476690
fix(collab): render doc despite an expired token
JammingBen Aug 7, 2026
7f38dfa
fix(collab): isDirty and readOnly tracking
JammingBen Aug 7, 2026
18bf044
fix(realtime): auth guard setting the app version
JammingBen Aug 7, 2026
93b2e23
refactor(realtime): remove unnecessary HEAD, more cleanup
JammingBen Aug 7, 2026
faf7cfd
refactor(collab): cleanup
JammingBen Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**/node_modules
**/dist
.git
.pnpm-store
release
reports
tests/e2e/.features-gen
third-party-licenses
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ OpenCloud Web is a TypeScript/Vue 3 single-page application that serves as the b
│ ├── tsconfig/ # Shared TypeScript configuration
│ ├── extension-sdk/ # Utilities for custom extensions
│ └── web-app-*/ # Standalone apps/extensions (files, search, preview, …)
├── services/ # Server-side sidecars shipped as their own docker images
│ └── realtime/ # Hocuspocus/Yjs server for realtime collaboration
├── tests/
│ └── e2e/ # End-to-end tests (Playwright + Playwright BDD)
├── dev/ # Docker/infrastructure config for local development
Expand Down
9 changes: 6 additions & 3 deletions dev/docker/opencloud/proxy.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# This adds four additional routes to the proxy. Forwarding
# request on '/carddav/', '/caldav/' and the respective '/.well-knwown'
# endpoints to the radicale container and setting the required headers.
# This adds five additional routes to the proxy: '/realtime' to the realtime
# container, and '/caldav/', '/carddav/' plus their respective '/.well-known'
# endpoints to radicale. Also sets the headers those backends require.
additional_policies:
- name: default
routes:
- endpoint: /realtime
backend: http://realtime:1234
unprotected: true
- endpoint: /caldav/
backend: http://host.docker.internal:5232
remote_user_header: X-Remote-User
Expand Down
374 changes: 374 additions & 0 deletions dev/docs/collaboration.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ x-opencloud-server: &opencloud-service
OCM_OCM_SHARE_PROVIDER_INSECURE: 'true'
OCM_OCM_STORAGE_PROVIDER_INSECURE: 'true'

# Yjs collaboration
WEB_OPTION_YJS_SERVER_URL: 'wss://host.docker.internal:9200/realtime'
extra_hosts:
- host.docker.internal:${DOCKER_HOST:-host-gateway}
- oc.opencloud.test:${DOCKER_HOST:-host-gateway}
Expand Down Expand Up @@ -425,6 +427,33 @@ services:
depends_on:
- stalwart

realtime:
build:
# Repository root, the pnpm lockfile is needed for the install
context: .
dockerfile: services/realtime/Dockerfile
extra_hosts:
- host.docker.internal:${DOCKER_HOST:-host-gateway}
environment:
PORT: '1234'
OPENCLOUD_URL: https://host.docker.internal:9200
# Dev only: accept the self-signed cert from OC's Traefik
NODE_TLS_REJECT_UNAUTHORIZED: '0'
# Dev only: allows the integration tests to bypass real OIDC tokens
NODE_ENV: development
DEV_FAKE_TOKEN: dev-integration-token
volumes:
# Dev only: edit the server without rebuilding the image
- ./services/realtime/src:/app/src:ro
networks:
# On the traefik network so OC (which is also on it) can reach the
# service by name. Browser-facing routing is not done at the Traefik
# layer - OC's reverse proxy forwards /realtime via
# `additional_policies` in opencloud/proxy.yaml. This keeps the routing
# model identical to what CI uses (CI has no Traefik).
- traefik
restart: unless-stopped

volumes:
uploads:
opencloud-config:
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
"scripts": {
"build": "pnpm vite build",
"build:w": "pnpm build --watch",
"lint": "eslint vite.config.ts '{packages,tests}/**/*.{js,ts,vue}' --color",
"lint": "eslint vite.config.ts '{packages,services,tests}/**/*.{js,ts,vue}' --color",
"format:check": "prettier . --config packages/prettier-config/index.js --check",
"format:write": "prettier . --config packages/prettier-config/index.js --write",
"serve": "SERVER=true pnpm build:w",
"test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest --config ./tests/unit/config/vitest.config.ts",
"licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;@opencloud-eu/web-test-helpers'",
"licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;web-realtime-server;@opencloud-eu/web-test-helpers'",
"licenses:csv": "license-checker-rseidelsohn --relativeLicensePath --csv --out ./third-party-licenses/third-party-licenses.csv",
"licenses:save": "license-checker-rseidelsohn --relativeLicensePath --out /dev/null --files ./third-party-licenses/third-party-licenses",
"vite": "vite",
Expand Down
18 changes: 9 additions & 9 deletions packages/web-app-epub-reader/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@

<script setup lang="ts">
import { computed, nextTick, ref, unref, watch } from 'vue'
import { Resource } from '@opencloud-eu/web-client'
import {
AppConfigObject,
Key,
useKeyboardActions,
useLocalStorage,
useThemeStore
useThemeStore,
type AppWrapperSlotProps,
type EditorSlotProps
} from '@opencloud-eu/web-pkg'
import ePub, { Book, NavItem, Rendition, Location } from 'epubjs'

Expand All @@ -120,12 +120,12 @@ const MAX_FONT_SIZE_PERCENTAGE = 150
const MIN_FONT_SIZE_PERCENTAGE = 50
const FONT_SIZE_PERCENTAGE_STEP = 10

const { currentContent, resource } = defineProps<{
applicationConfig: AppConfigObject
currentContent: string
resource: Resource
isReadOnly?: boolean
}>()
// `applicationConfig` is declared but never read here. Without it the wrapper's
// slot binding has nowhere to land it and Vue falls it through to the root
// element as `applicationconfig="[object Object]"`.
const { currentContent, resource } = defineProps<
EditorSlotProps & Pick<AppWrapperSlotProps, 'applicationConfig'>
>()

const keyboardActions = useKeyboardActions()
const bookContainer = ref<Element>()
Expand Down
10 changes: 3 additions & 7 deletions packages/web-app-external/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,13 @@ import { useTask } from 'vue-concurrency'
import { useGettext } from 'vue3-gettext'
import {
GraphSharePermission,
Resource,
SpaceResource,
isProjectSpaceResource,
isPublicSpaceResource,
isShareSpaceResource
} from '@opencloud-eu/web-client'
import { urlJoin } from '@opencloud-eu/web-client'
import {
AppWrapperSlotProps,
isSameResource,
useCapabilityStore,
useConfigStore,
Expand All @@ -79,11 +78,8 @@ import {
import { storeToRefs } from 'pinia'
import { useCollaboraPostMessages } from './composables'

const { space, resource, isReadOnly } = defineProps<{
space: SpaceResource
resource: Resource
isReadOnly: boolean
}>()
const { space, resource, isReadOnly } =
defineProps<Pick<AppWrapperSlotProps, 'space' | 'resource' | 'isReadOnly'>>()

defineEmits(['save', 'close']) // these are inherited from the AppWrapper.vue

Expand Down
7 changes: 2 additions & 5 deletions packages/web-app-pdf-viewer/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,9 @@
</template>

<script setup lang="ts">
import { Resource } from '@opencloud-eu/web-client'
import type { ViewerSlotProps } from '@opencloud-eu/web-pkg'

const { url, resource = undefined } = defineProps<{
url: string
resource?: Resource
}>()
const { url, resource } = defineProps<ViewerSlotProps>()

const userAgent = navigator.userAgent || ''

Expand Down
13 changes: 2 additions & 11 deletions packages/web-app-preview/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,9 @@ import {
import omit from 'lodash-es/omit'
import { IncomingShareResource, Resource } from '@opencloud-eu/web-client'
import {
AppFileHandlingResult,
AppFolderHandlingResult,
createFileRouteOptions,
determineResourceTableSortFields,
FileContext,
FolderViewerSlotProps,
isLocationSharesActive,
Key,
Modifier,
Expand Down Expand Up @@ -137,14 +135,7 @@ const {
getUrlForResource,
revokeUrl,
isFolderLoading
} = defineProps<{
activeFiles: Resource[]
currentFileContext: FileContext
loadFolderForFileContext: AppFolderHandlingResult['loadFolderForFileContext']
getUrlForResource: AppFileHandlingResult['getUrlForResource']
revokeUrl: AppFileHandlingResult['revokeUrl']
isFolderLoading: boolean
}>()
} = defineProps<FolderViewerSlotProps>()

const emit = defineEmits<{
(e: 'update:resource', resource: Resource): void
Expand Down
5 changes: 4 additions & 1 deletion packages/web-app-text-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"description": "OpenCloud Web simple text editor",
"license": "AGPL-3.0",
"devDependencies": {
"@opencloud-eu/web-test-helpers": "workspace:*"
"@opencloud-eu/web-test-helpers": "workspace:*",
"@tiptap/vue-3": "^3.20.4",
"y-protocols": "^1.0.7",
"yjs": "^13.6.0"
},
"peerDependencies": {
"@opencloud-eu/design-system": "workspace:^",
Expand Down
56 changes: 21 additions & 35 deletions packages/web-app-text-editor/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,59 +10,45 @@
<script setup lang="ts">
import { computed, toRef, unref } from 'vue'
import { useGettext } from 'vue3-gettext'
import type { CollaborativeEditorSlotProps } from '@opencloud-eu/web-pkg'
import {
ContentType,
useTextEditor,
TextEditorProvider,
TextEditorContent,
TextEditorToolbar
TextEditorToolbar,
type ContentType
} from '@opencloud-eu/web-pkg/editor'
import type { Resource } from '@opencloud-eu/web-client'

const {
currentContent,
contentType = undefined,
isReadOnly = false,
resource
} = defineProps<{
currentContent: string
contentType?: ContentType
isReadOnly?: boolean
resource: Resource
}>()

const emit = defineEmits<{
(e: 'update:currentContent', value: string): void
}>()
import { detectContentType } from './collab'

// AppWrapper keeps its loading screen up until the collaborative session has
// synced and hydrated, so `ydoc` and `awareness` are ready by the time this
// component mounts. The editor binds to the shared Y.Doc through
// `@tiptap/extension-collaboration` inside `useTextEditor`.
// `currentContent` is declared by `CollaborativeEditorSlotProps` but never read
// here. It makes AppWrapper fetch the file, which the collaborative session
// uses to seed an empty Y.Doc. Once the session is up the Y.Doc is the source
// of truth.
const { ydoc, awareness, isReadOnly, resource } = defineProps<CollaborativeEditorSlotProps>()

const { $gettext } = useGettext()

const parsedContentType = computed<ContentType>(() => {
if (contentType !== undefined) {
return contentType
}
const ext = resource?.extension?.toLowerCase()
const mimeType = resource?.mimeType?.toLowerCase()
if (ext === 'md' || ext === 'markdown' || mimeType === 'text/markdown') {
return 'markdown'
}

return 'plain-text'
const contentType = computed<ContentType>(() => {
return detectContentType(resource)
})

const placeholder = computed(() => {
if (isReadOnly || unref(parsedContentType) !== 'markdown') {
if (isReadOnly || unref(contentType) !== 'markdown') {
return undefined
}
return $gettext('Write or type / for formatting options...')
})

const textEditor = useTextEditor({
contentType: unref(parsedContentType),
contentType: unref(contentType),
currentResource: toRef(() => resource),
modelValue: toRef(() => currentContent),
readonly: isReadOnly,
readonly: () => isReadOnly,
placeholder: unref(placeholder),
onUpdate: (content) => emit('update:currentContent', content)
ydoc,
awareness
})
</script>
54 changes: 54 additions & 0 deletions packages/web-app-text-editor/src/collab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ref, unref } from 'vue'
import type { Resource } from '@opencloud-eu/web-client'
import type { CollaborativeAdapter, CollaborativeAdapterContext } from '@opencloud-eu/web-pkg'
import {
makeTiptapCollabAdapter,
useContentStrategy,
type ContentType,
type ContentTypeStrategy,
type TextEditorLinkPanelRequest,
type TextEditorState
} from '@opencloud-eu/web-pkg/editor'

/** The content types this app can produce. A subset of {@link ContentType}. */
export type TextEditorContentType = Extract<ContentType, 'markdown' | 'plain-text'>

export function detectContentType(resource: Resource): TextEditorContentType {
const extension = resource?.extension?.toLowerCase()
const mimeType = resource?.mimeType?.toLowerCase()
if (extension === 'md' || extension === 'markdown' || mimeType === 'text/markdown') {
return 'markdown'
}

return 'plain-text'
}

/**
* Builds the Y.Doc bridge the AppWrapper's collaborative session runs on.
*
* Called during the wrapper's setup, before the file is loaded, so the
* content type is resolved lazily, per call, from the resource ref. The
* strategies themselves must be built eagerly: they call `useGettext()`, which
* only works while a setup context is active.
*/
export function makeTextEditorAdapter({
resource
}: CollaborativeAdapterContext): CollaborativeAdapter {
const { resolveStrategy } = useContentStrategy()

// The adapter needs its own editor state for the headless fallback path; the
// mounted editor resolves its own inside `useTextEditor`.
const state: TextEditorState = {
sourceMode: ref(false),
linkPanel: ref<TextEditorLinkPanelRequest | null>(null),
editorZoom: ref(100),
currentResource: resource
}

const strategies: Record<TextEditorContentType, ContentTypeStrategy> = {
'plain-text': resolveStrategy('plain-text', state),
markdown: resolveStrategy('markdown', state)
}

return makeTiptapCollabAdapter(() => strategies[detectContentType(unref(resource))])
}
5 changes: 4 additions & 1 deletion packages/web-app-text-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from '@opencloud-eu/web-pkg'
import { computed } from 'vue'
import { urlJoin } from '@opencloud-eu/web-client'
import { makeTextEditorAdapter } from './collab'
import pkg from '../package.json'

export default defineWebApplication({
setup({ applicationConfig }) {
Expand Down Expand Up @@ -200,7 +202,8 @@ export default defineWebApplication({
{
path: '/:driveAliasAndItem(.*)?',
component: AppWrapperRoute(TextEditor, {
applicationId: appId
applicationId: appId,
collaborative: { appVersion: pkg.version, makeAdapter: makeTextEditorAdapter }
}),
name: 'text-editor',
meta: {
Expand Down
Loading