Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
41 changes: 13 additions & 28 deletions cypress/e2e/api/SessionApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('The session Api', function() {
cy.wrap(connection)
.its('document.id')
.should('equal', fileId)
connection.close()
cy.destroySession(connection)
})
})

Expand All @@ -51,7 +51,7 @@ describe('The session Api', function() {
cy.wrap(connection)
.its('state.documentSource')
.should('eql', '## Hello world\n')
connection.close()
cy.destroySession(connection)
})
})

Expand Down Expand Up @@ -81,7 +81,7 @@ describe('The session Api', function() {
})

afterEach(function() {
connection.close()
cy.destroySession(connection)
})

// Echoes all message types but queries
Expand Down Expand Up @@ -180,7 +180,7 @@ describe('The session Api', function() {
})

afterEach(function() {
connection.close()
cy.destroySession(connection)
})
})

Expand Down Expand Up @@ -212,7 +212,7 @@ describe('The session Api', function() {
})

afterEach(function() {
connection.close()
cy.destroySession(connection)
})

it('starts empty public', function() {
Expand Down Expand Up @@ -276,22 +276,6 @@ describe('The session Api', function() {
})
})

it('signals closing connection', function() {
cy.then(() => {
return new Promise((resolve, reject) => {
// Create a promise that resolves when close completes
connection.close()
.then(() => {
connection.push({ steps: [messages.update], version, awareness: '' })
.then(
() => reject(new Error('Push should have thrown ConnectionClosed()')),
resolve,
)
})
})
})
})

it('does not send initial content if other session is alive but did not push any steps', function() {
let joining
cy.createTextSession(undefined, { filePath: '', shareToken })
Expand All @@ -301,8 +285,8 @@ describe('The session Api', function() {
})
.its('state.documentSource')
.should('eql', '## Hello world\n')
.then(() => joining.close())
.then(() => connection.close())
.then(() => cy.destroySession(joining))
cy.destroySession(connection)
})

it('does not send initial content if session is alive even without saved state', function() {
Expand All @@ -317,16 +301,17 @@ describe('The session Api', function() {
})
.its('state.documentSource')
.should('eql', '## Hello world\n')
.then(() => joining.close())
.then(() => connection.close())
.then(() => cy.destroySession(joining))
cy.destroySession(connection)
})

it('refuses create,push,sync,save with non-matching baseVersionEtag', function() {
cy.failToCreateTextSession(undefined, 'wrongBaseVersionEtag', { filePath: '', shareToken })
cy.failToCreateTextSession(undefined, 'wrongBaseVersionEtag', { filePath: '', token: shareToken })
.its('status')
.should('eql', 412)

connection.setBaseVersionEtag('wrongBaseVersionEtag')
connection.connection.baseVersionEtag = 'wrongBaseVersionEtag'

cy.failToPushSteps({ connection, steps: [messages.update], version })
.its('status')
Expand All @@ -340,7 +325,7 @@ describe('The session Api', function() {
.its('status')
.should('equal', 412)

cy.then(() => connection.close())
cy.destroySession(connection)
})

it('recovers session even if last person leaves right after create', function() {
Expand All @@ -355,7 +340,7 @@ describe('The session Api', function() {
joining = con
})
cy.log('Initial user closes session')
.then(() => connection.close())
cy.destroySession(connection)
cy.log('Other user still finds the steps')
.then(() => {
cy.syncSteps(joining, {
Expand Down
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.js'
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
2 changes: 1 addition & 1 deletion cypress/e2e/api/UsersApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('The user mention API', function() {
})

it('rejects closed sessions', function() {
cy.then(() => this.connection.close())
cy.destroySession(this.connection)
cy.sessionUsers(this.connection)
.its('status').should('eq', 403)
})
Expand Down
41 changes: 26 additions & 15 deletions cypress/support/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,44 @@
*/

import axios from '@nextcloud/axios'
import SessionApi from '../../src/services/SessionApi.js'
import { SessionConnection } from '../../src/services/SessionConnection.js'
import { open, close } from '../../src/apis/Connect.ts'
import { push } from '../../src/apis/Sync.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 { connection, data } = await open({ fileId, token: options.shareToken, ...options })
return new SessionConnection(data, connection)
})

Cypress.Commands.add('destroySession', async (sessionConnection) => {
const { documentId, id, token } = sessionConnection.session
await close({ documentId, sessionId: id, sessionToken: token })
sessionConnection.close()
})

Cypress.Commands.add('failToCreateTextSession', (fileId, baseVersionEtag = null, options = {}) => {
const api = new SessionApi(options)
return api.open({ fileId, baseVersionEtag })
.then((response) => {
return open({ fileId, ...options, baseVersionEtag })
.then((_response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
})

Cypress.Commands.add('pushSteps', ({ connection, steps, version, awareness = '' }) => {
return connection.push({ steps, version, awareness })
.then(response => response.data)
Cypress.Commands.add('pushSteps', ({ connection: sessionConnection, steps, version, awareness = '' }) => {
return push(
sessionConnection.connection,
{ steps, version, awareness }
).then(response => response.data)
})

Cypress.Commands.add('failToPushSteps', ({ connection, steps, version, awareness = '' }) => {
return connection.push({ steps, version, awareness })
.then((response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
Cypress.Commands.add('failToPushSteps', ({ connection: sessionConnection, steps, version, awareness = '' }) => {
return push(
sessionConnection.connection,
{ steps, version, awareness }
).then((_response) => {
throw new Error('Expected request to fail - but it succeeded!')
}, (err) => err.response)
})

Cypress.Commands.add('syncSteps', (connection, options = { version: 0 }) => {
Expand Down
14 changes: 3 additions & 11 deletions src/EditorFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,9 @@ const createRichEditor = ({ extensions = [], connection, relativePath, isEmbedde
return new Editor({
editorProps,
extensions: [
RichText.configure({
relativePath,
isEmbedded,
component: this,
extensions: [
Mention.configure({
suggestion: MentionSuggestion({
connection,
}),
}),
],
RichText.configure({ relativePath, isEmbedded }),
Mention.configure({
suggestion: MentionSuggestion({ connection }),
}),
FocusTrap,
...extensions,
Expand Down
58 changes: 58 additions & 0 deletions src/apis/Connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* SPDX-FileCopyrightText: 2025 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,
baseVersionEtag: document.baseVersionEtag,
filePath: params.filePath,
shareToken: params.token,
}
return { connection, data: response.data }
}

Check warning on line 43 in src/apis/Connect.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Connect.ts#L25-L43

Added lines #L25 - L43 were not covered by tests

/**
* 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, {
documentId: connection.documentId,
sessionId: connection.sessionId,
sessionToken: connection.sessionToken,
})
return response.data
}

Check warning on line 58 in src/apis/Connect.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Connect.ts#L49-L58

Added lines #L49 - L58 were not covered by tests
67 changes: 67 additions & 0 deletions src/apis/Mention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* SPDX-FileCopyrightText: 2025 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'
import { unref, type ShallowRef } from 'vue'

/**
* Let Nextcloud know someone was mentioned
* @param mention user id of the person that was mentioned
* @param scope scope the user was mentioned in
* @param options options
* @param options.connection connection to the text editing session
*/
export function emitMention(
mention: string,
scope: object,
{ connection }: { connection: ShallowRef<Connection> | Connection },
): Promise<void> {

Check warning on line 22 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L19-L22

Added lines #L19 - L22 were not covered by tests
// TODO: Require actual connection - handle disconnected state early on
const con = unref(connection)
if (!con) {
const err = new Error('Disconnected. Could not notify user about mention.')
console.warn(err.message, { err, mention })
return Promise.resolve()
}
const url = generateUrl(`apps/text/session/${con.documentId}/mention`)
return axios.put(url, {
documentId: con.documentId,
sessionId: con.sessionId,
sessionToken: con.sessionToken,
mention,
scope,
})

Check warning on line 37 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L24-L37

Added lines #L24 - L37 were not covered by tests
// TODO: handle errors:
// * signal something is wrong with the connection
// * wait for reconnect and fetch again
}

Check warning on line 41 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L41

Added line #L41 was not covered by tests

const USERS_LIST_ENDPOINT_URL = generateUrl('apps/text/api/v1/users')

/**
* Look up user names to mention
* @param filter string to look for in the user names
* @param options options
* @param options.connection connection to the text editing session
*/
export async function getUsers(
filter: string,
{ connection }: { connection: ShallowRef<Connection> },
): Promise<Record<string, string>> {

Check warning on line 54 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L51-L54

Added lines #L51 - L54 were not covered by tests
// TODO: Require actual connection - handle disconnected state early on
const con = unref(connection)
if (!con) {
const err = new Error('Disconnected. Could not lookup users to mention.')
console.warn(err.message, { err })
return Promise.resolve({})
}
const response = await axios.post(USERS_LIST_ENDPOINT_URL, { ...con, filter })

Check warning on line 62 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L56-L62

Added lines #L56 - L62 were not covered by tests
// TODO: handle errors:
// * signal something is wrong with the connection
// * wait for reconnect and fetch again
return JSON.parse(JSON.stringify(response.data))
}

Check warning on line 67 in src/apis/Mention.ts

View check run for this annotation

Codecov / codecov/patch

src/apis/Mention.ts#L66-L67

Added lines #L66 - L67 were not covered by tests
Loading
Loading