Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
95764c6
chore(split): SaveService from SyncService
max-nextcloud Jul 1, 2025
2405e75
chore(type): sync service with typescript
max-nextcloud Jul 2, 2025
8525912
chore(migrate): sync service mixin to composable
max-nextcloud Jul 2, 2025
507a7a5
chore(refactor): watch sync service to create save service
max-nextcloud Jul 3, 2025
594fb1a
chore(refactor): move connectSyncService into useSyncService composable
max-nextcloud Jul 3, 2025
725872c
refactor(compose): migrate save service to composable
max-nextcloud Jul 3, 2025
9fdf878
refactor(editor): detect rich editor based on markdown extension
max-nextcloud Jul 3, 2025
94f7619
refactor(editor): always provide an editor
max-nextcloud Jul 3, 2025
e8d184a
refactor(cleanup): unwrap connection
max-nextcloud Jul 3, 2025
c856f2f
chore(minor): clean up redundant injects
max-nextcloud Jul 3, 2025
00c18c4
chore(refactor): simplify types for props
max-nextcloud Jul 3, 2025
c71503e
chore(refactor): watch sync service in useConnection
max-nextcloud Jul 5, 2025
7265f02
chore(extract): Mentions api from extension
max-nextcloud Jul 5, 2025
f6b76f3
chore(simplify): SyncService.open returns void
max-nextcloud Jul 5, 2025
90ae016
chore(simplify): combine loaded and opened event
max-nextcloud Jul 5, 2025
8a46d28
chore(cleanup): unused getter
max-nextcloud Jul 5, 2025
798c4b8
fix(menu): call base components setup function
max-nextcloud Jul 5, 2025
8b45a52
chore(refactor): connect from useConnection composable
max-nextcloud Jul 5, 2025
7bc34c9
chore(cleanup): sync and save service are always defined now
max-nextcloud Jul 6, 2025
d4a09c4
test(cy): properly close connections
max-nextcloud Jul 6, 2025
e6fc63b
chore(refactor): sync service with new connection
max-nextcloud Jul 7, 2025
c3230d3
chore(refactor): instantiate SessionConnection with plain data
max-nextcloud Jul 7, 2025
7dc8e25
fix(sync): stop autosave when closing connection
max-nextcloud Jul 8, 2025
7c52d1c
chore(cleanup): ? on attributes that are always truthy
max-nextcloud Jul 8, 2025
f370913
chore(cleanup): avoid reuse of isRichEditor name
max-nextcloud Jul 8, 2025
6c76f27
chore(cleanup): remove outdated comment
max-nextcloud Jul 8, 2025
9ea9857
chore(copyright): fix year to 2025
max-nextcloud Jul 8, 2025
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
Prev Previous commit
Next Next commit
chore(refactor): connect from useConnection composable
Signed-off-by: Max <[email protected]>
  • Loading branch information
max-nextcloud committed Jul 7, 2025
commit 8b45a52d64c533d852d282b5d162db72531d1285
13 changes: 5 additions & 8 deletions cypress/e2e/api/SyncServiceProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*/

import { randUser } from '../../utils/index.js'
import SessionApi from '../../../src/services/SessionApi.js'
import { SyncService } from '../../../src/services/SyncService.ts'
import { provideConnection } from '../../../src/composables/useConnection.ts'
import { provideSyncService } from '../../../src/composables/useSyncService.ts'
import createSyncServiceProvider from '../../../src/services/SyncServiceProvider.js'
import { Doc } from 'yjs'

Expand Down Expand Up @@ -39,13 +39,10 @@ describe('Sync service provider', function() {
* @param {object} ydoc Yjs document
*/
function createProvider(ydoc) {
const relativePath = '.'
const { connection, openConnection, baseVersionEtag } = provideConnection({ fileId, relativePath })
const { syncService } = provideSyncService( connection, openConnection, baseVersionEtag )
const queue = []
const api = new SessionApi()
const syncService = new SyncService({
serialize: () => 'Serialized',
getDocumentState: () => null,
api,
})
syncService.on('opened', () => syncService.startSync())
return createSyncServiceProvider({
ydoc,
Expand Down
14 changes: 7 additions & 7 deletions cypress/support/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
*/

import axios from '@nextcloud/axios'
import SessionApi from '../../src/services/SessionApi.js'
import { Connection } from '../../src/services/SessionApi.js'
import { open } from '../../src/apis/Connect.ts'

const url = Cypress.config('baseUrl').replace(/\/index.php\/?$/g, '')

Cypress.Commands.add('createTextSession', (fileId, options = {}) => {
const api = new SessionApi(options)
return api.open({ fileId })
Cypress.Commands.add('createTextSession', async (fileId, options = {}) => {
const { data } = await open({ fileId, ...options })
return new Connection({ data }, options)
})

Cypress.Commands.add('failToCreateTextSession', (fileId, baseVersionEtag = null, options = {}) => {
const api = new SessionApi(options)
return api.open({ fileId, baseVersionEtag })
.then((response) => {
open({ fileId, ...options, baseVersionEtag })
.then((_response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
})
Expand Down
51 changes: 51 additions & 0 deletions src/apis/Connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import type { Connection } from '../composables/useConnection.js'

export interface OpenParams {
fileId?: number
baseVersionEtag?: string
filePath: string
token?: string
guestName?: string
}

export interface OpenData {
document: { baseVersionEtag: string }
}

/**
* Open editing connection to the document
* @param params Parameters identifying the document
*/
export async function open(
params: OpenParams,
): Promise<{ connection: Connection; data: OpenData }> {
const _baseUrl = params.token
? generateUrl('/apps/text/public')
: generateUrl('/apps/text')
const url = `${_baseUrl}/session/${params.fileId}/create`
const response = await axios.put(url, params)
const { document, session } = response.data
const connection = {
documentId: document.id,
sessionId: session.id,
sessionToken: session.token,
}
return { connection, data: response.data }
}

/**
* Close the connection
* @param connection connection to close
*/
export async function close(connection: Connection) {
const id = connection.documentId
const url = generateUrl(`/apps/text/session/${id}/close`)
const response = await axios.post(url, connection)
return response.data
}
8 changes: 3 additions & 5 deletions src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@ export default defineComponent({
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({ provider: { awareness } }),
]
const { syncService, connectSyncService, baseVersionEtag } =
provideSyncService(props)
const { connection } = provideConnection(syncService)
const { connection, openConnection, baseVersionEtag } =
provideConnection(props)
const { syncService } = provideSyncService(connection, openConnection, props)
const editor = isRichEditor
? createRichEditor({
connection,
Expand All @@ -273,7 +273,6 @@ export default defineComponent({
return {
awareness,
baseVersionEtag,
connectSyncService,
editor,
el,
hasConnectionIssue,
Expand Down Expand Up @@ -441,7 +440,6 @@ export default defineComponent({
},
methods: {
initSession() {
this.connectSyncService()
this.listenSyncServiceEvents()
this.syncProvider = createSyncServiceProvider({
ydoc: this.ydoc,
Expand Down
89 changes: 59 additions & 30 deletions src/composables/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,82 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import {
inject,
provide,
shallowRef,
watch,
type InjectionKey,
type ShallowRef,
} from 'vue'
import type { Session, SyncService } from '../services/SyncService.js'
import { inject, provide, shallowRef, type InjectionKey, type ShallowRef } from 'vue'
import { open } from '../apis/Connect.js'
import type { Document, Session } from '../services/SyncService.js'

export interface Connection {
documentId: number
sessionId: number
sessionToken: string
}

export interface InitialData {
document: Document
session: Session
readOnly: boolean
content: string
documentState?: string
lock?: object
hasOwner: boolean
}

export const connectionKey = Symbol('text:connection') as InjectionKey<
ShallowRef<Connection | undefined>
>

export const provideConnection = (
syncService: ShallowRef<SyncService | undefined>,
) => {
/**
* Handle the connection to the text api and provide it to child components
* @param props Props of the editor component.
* @param props.fileId Fileid of the file.
* @param props.relativePath Relative path to the file.
* @param props.initialSession Initial session handed to the editor in direct editing
* @param props.shareToken Share token of the file.
*/
export function provideConnection(props: {
fileId: number
relativePath: string
initialSession?: InitialData
shareToken?: string
}) {
const baseVersionEtag = shallowRef<string | undefined>(undefined)
const connection = shallowRef<Connection | undefined>(undefined)
const updateConnection = ({ session }: { session: Session }) => {
connection.value = {
documentId: session.documentId,
sessionId: session.id,
sessionToken: session.token,
}
const openConnection = async () => {
const guestName = localStorage.getItem('nick') ?? ''
const { connection: opened, data } =
openInitialSession(props)
|| (await open({
fileId: props.fileId,
guestName,
token: props.shareToken,
filePath: props.relativePath,
baseVersionEtag: baseVersionEtag.value,
}))
baseVersionEtag.value = data.document.baseVersionEtag
connection.value = opened
return data
}
syncService.value?.bus.on('opened', updateConnection)
watch(
syncService,
(newSyncService?: SyncService, oldSyncService?: SyncService) => {
newSyncService?.bus.on('opened', updateConnection)
oldSyncService?.bus.off('opened', updateConnection)
if (!newSyncService) {
connection.value = undefined
}
},
)
provide(connectionKey, connection)
return { connection }
return { connection, openConnection, baseVersionEtag }
}

export const useConnection = () => {
const connection = inject(connectionKey)
return { connection }
}

/**
* Get the connection and additional data from the initial session if available.
* @param props Props of the editor component
* @param props.initialSession InitialSession to use.
*/
function openInitialSession(props: { initialSession?: InitialData }) {
if (props.initialSession) {
const connection = {
documentId: props.initialSession.document.id,
sessionId: props.initialSession.session.id,
sessionToken: props.initialSession.session.token,
}
return { connection, data: props.initialSession }
}
}
34 changes: 8 additions & 26 deletions src/composables/useSaveService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,29 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import {
type InjectionKey,
type ShallowRef,
shallowRef,
provide,
inject,
watch,
} from 'vue'
import { type InjectionKey, provide, inject } from 'vue'
import { SaveService } from '../services/SaveService.js'
import type { SyncService } from '../services/SyncService.ts'
import type { Doc } from 'yjs'
import { getDocumentState } from '../helpers/yjs.js'

const saveServiceKey = Symbol('text:save') as InjectionKey<
ShallowRef<SaveService | undefined>
>
const saveServiceKey = Symbol('text:save') as InjectionKey<SaveService>

export const provideSaveService = (
syncService: ShallowRef<SyncService>,
syncService: SyncService,
serialize: () => string,
ydoc: Doc,
) => {
const saveService: ShallowRef<SaveService | undefined> = shallowRef(undefined)

watch(syncService, (newSyncService) => {
if (!newSyncService) {
saveService.value = undefined
return
}
saveService.value = new SaveService({
syncService: newSyncService,
serialize,
getDocumentState: () => getDocumentState(ydoc),
})
const saveService = new SaveService({
syncService,
serialize,
getDocumentState: () => getDocumentState(ydoc),
})

provide(saveServiceKey, saveService)
return { saveService }
}

export const useSaveService = () => {
const saveService = inject(saveServiceKey, shallowRef(undefined))
const saveService = inject(saveServiceKey) as SaveService
return { saveService }
}
48 changes: 20 additions & 28 deletions src/composables/useSyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { type InjectionKey, type ShallowRef, shallowRef, provide, inject } from 'vue'
import { SyncService } from '../services/SyncService.js'
import SessionApi from '../services/SessionApi.js'
import { type InjectionKey, type ShallowRef, provide, inject } from 'vue'
import { SyncService } from '../services/SyncService'
import type { Connection, InitialData } from './useConnection.js'

const syncServiceKey = Symbol('text:sync') as InjectionKey<
ShallowRef<SyncService | undefined>
>
const syncServiceKey = Symbol('text:sync') as InjectionKey<SyncService>

/**
* Define a sync service and provide it to child components
* @param connection Connection to the text api.
* @param openConnection Function to open the connection.
* @param props Props of the editor component.
* @param props.relativePath Relative path to the file.
* @param props.shareToken Share token of the file.
* @param props.relativePath Relative path to the file.
*/
export function provideSyncService(props: {
relativePath: string
shareToken?: string
}) {
const syncService: ShallowRef<SyncService | undefined> = shallowRef(undefined)
const baseVersionEtag = shallowRef(undefined)
export function provideSyncService(
connection: ShallowRef<Connection>,
openConnection: () => Promise<InitialData>,
props: { shareToken: string; relativePath: string },
) {
const syncService = new SyncService({
connection,
openConnection,
shareToken: props.shareToken,
filePath: props.relativePath,
})
provide(syncServiceKey, syncService)
const connectSyncService = () => {
const guestName = localStorage.getItem('nick') ?? ''
const api = new SessionApi({
guestName,
shareToken: props.shareToken,
filePath: props.relativePath,
})
syncService.value = new SyncService({
api,
baseVersionEtag: baseVersionEtag.value,
})
}

return { baseVersionEtag, connectSyncService, syncService }
return { syncService }
}

export const useSyncService = () => {
const syncService = inject(syncServiceKey, shallowRef(undefined))
const syncService = inject(syncServiceKey) as SyncService
return { syncService }
}
Loading