diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 942370f2710..98eb5a135a1 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -42,7 +42,7 @@ describe('The session Api', function() { cy.wrap(connection) .its('document.id') .should('equal', fileId) - connection.close() + cy.destroySession(connection) }) }) @@ -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) }) }) @@ -81,7 +81,7 @@ describe('The session Api', function() { }) afterEach(function() { - connection.close() + cy.destroySession(connection) }) // Echoes all message types but queries @@ -180,7 +180,7 @@ describe('The session Api', function() { }) afterEach(function() { - connection.close() + cy.destroySession(connection) }) }) @@ -212,7 +212,7 @@ describe('The session Api', function() { }) afterEach(function() { - connection.close() + cy.destroySession(connection) }) it('starts empty public', function() { @@ -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 }) @@ -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() { @@ -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') @@ -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() { @@ -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, { diff --git a/cypress/e2e/api/SyncServiceProvider.spec.js b/cypress/e2e/api/SyncServiceProvider.spec.js index 614e0983cc4..729fcb8c81c 100644 --- a/cypress/e2e/api/SyncServiceProvider.spec.js +++ b/cypress/e2e/api/SyncServiceProvider.spec.js @@ -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' @@ -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, diff --git a/cypress/e2e/api/UsersApi.spec.js b/cypress/e2e/api/UsersApi.spec.js index 9c9acabcb8b..ee9ee8cbe50 100644 --- a/cypress/e2e/api/UsersApi.spec.js +++ b/cypress/e2e/api/UsersApi.spec.js @@ -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) }) diff --git a/cypress/support/sessions.js b/cypress/support/sessions.js index f297f8e8ce9..ed865f3e79b 100644 --- a/cypress/support/sessions.js +++ b/cypress/support/sessions.js @@ -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 }) => { diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 280a5fe27f3..ee7d9c37442 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -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, diff --git a/src/apis/Connect.ts b/src/apis/Connect.ts new file mode 100644 index 00000000000..aa615392946 --- /dev/null +++ b/src/apis/Connect.ts @@ -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 } +} + +/** + * 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 +} diff --git a/src/apis/Mention.ts b/src/apis/Mention.ts new file mode 100644 index 00000000000..5c53390612c --- /dev/null +++ b/src/apis/Mention.ts @@ -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 }, +): Promise { + // 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, + }) + // TODO: handle errors: + // * signal something is wrong with the connection + // * wait for reconnect and fetch again +} + +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 }, +): Promise> { + // 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 }) + // TODO: handle errors: + // * signal something is wrong with the connection + // * wait for reconnect and fetch again + return JSON.parse(JSON.stringify(response.data)) +} diff --git a/src/apis/Sync.ts b/src/apis/Sync.ts new file mode 100644 index 00000000000..36077faecb5 --- /dev/null +++ b/src/apis/Sync.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import type { Connection } from '../composables/useConnection.js' +import { unref, type ShallowRef } from 'vue' +import { generateUrl } from '@nextcloud/router' +import type { Step } from '../services/SyncService.js' + +interface SyncData { + version: number + steps: string[] + awareness: string +} + +interface SyncResponse { + data: { + steps: Step[] + documentState: string + awareness: Record + } +} + +/** + * Send data to the server + * @param connection the active connection + * @param data data to push to the server + */ +export function push( + connection: ShallowRef | Connection, + data: SyncData, +): Promise { + const con = unref(connection) + const pub = con.shareToken ? '/public' : '' + const url = generateUrl(`apps/text${pub}/session/${con.documentId}/push`) + return axios.post(url, { + documentId: con.documentId, + sessionId: con.sessionId, + sessionToken: con.sessionToken, + token: con.shareToken, + baseVersionEtag: con.baseVersionEtag, + version: data.version, + steps: data.steps.filter((s) => s), + awareness: data.awareness, + }) +} diff --git a/src/components/Assistant.vue b/src/components/Assistant.vue index c9433fcd431..bcf94371d8a 100644 --- a/src/components/Assistant.vue +++ b/src/components/Assistant.vue @@ -268,7 +268,7 @@ export default { return } - this.editor?.on('selectionUpdate', this.onSelection) + this.editor.on('selectionUpdate', this.onSelection) this.fetchTasks() subscribe('notifications:notification:received', this.checkNotification) }, @@ -277,7 +277,7 @@ export default { return } - this.editor?.off('selectionUpdate', this.onSelection) + this.editor.off('selectionUpdate', this.onSelection) unsubscribe('notifications:notification:received', this.checkNotification) }, methods: { @@ -310,12 +310,8 @@ export default { await this.fetchTasks() }, onSelection() { - const { state } = this.editor ?? {} - if (!state) { - return - } - const { from, to } = state.selection - this.selection = state.doc.textBetween(from, to, ' ') + const { selection, doc } = this.editor.state + this.selection = doc.textBetween(selection.from, selection.to, ' ') }, async openAssistantForm(taskType = null) { await window.OCA.Assistant.openAssistantForm({ @@ -343,7 +339,7 @@ export default { }, openTranslateDialog() { if (!this.selection.trim().length) { - this.editor?.commands.selectAll() + this.editor.commands.selectAll() } emit('text:translate-modal:show', { content: this.selection || '' }) }, @@ -367,7 +363,7 @@ export default { const content = isMarkdown ? markdownit.render(task.output.output) : task.output.output - this.editor?.commands.insertContent(content) + this.editor.commands.insertContent(content) this.showTaskList = false }, async copyResult(task) { @@ -402,9 +398,9 @@ export default { .querySelector('.ProseMirror') .getBoundingClientRect() const pos = posToDOMRect( - this.editor?.view, - this.editor?.state.selection.from, - this.editor?.state.selection.to, + this.editor.view, + this.editor.state.selection.from, + this.editor.state.selection.to, ) let rightSpacing = 0 diff --git a/src/components/BaseReader.vue b/src/components/BaseReader.vue index 5e1587baf6d..63b2bfb1ab5 100644 --- a/src/components/BaseReader.vue +++ b/src/components/BaseReader.vue @@ -14,7 +14,6 @@ @@ -32,6 +31,7 @@ import { } from './Editor/Wrapper.provider.js' import EditorOutline from './Editor/EditorOutline.vue' import { useEditorMethods } from '../composables/useEditorMethods.ts' +import { inject, watch } from 'vue' export default { name: 'BaseReader', @@ -42,9 +42,6 @@ export default { mixins: [useOutlineStateMixin, useOutlineActions], - // extensions is a factory building a list of extensions for the editor - inject: ['renderHtml', 'extensions'], - props: { content: { type: String, @@ -52,48 +49,36 @@ export default { }, }, - setup() { - const { editor } = provideEditor() + setup(props) { + // extensions is a factory building a list of extensions for the editor + const extensions = inject('extensions') + const renderHtml = inject('renderHtml') + const editor = new Editor({ + content: renderHtml(props.content), + extensions: extensions(), + }) + provideEditor(editor) + watch( + () => props.content, + (content) => { + console.warn({ content }) + editor.commands.setContent(renderHtml(content), true) + }, + ) const { setEditable } = useEditorMethods(editor) - return { editor, setEditable } + setEditable(false) + return { editor } }, computed: { - htmlContent() { - return this.renderHtml(this.content) - }, showOutline() { return this.$outlineState.visible }, }, - watch: { - content() { - this.updateContent() - }, - }, - - created() { - this.editor = this.createEditor() - this.setEditable(false) - }, - beforeDestroy() { this.editor?.destroy() }, - - methods: { - createEditor() { - return new Editor({ - content: this.htmlContent, - extensions: this.extensions(), - }) - }, - - updateContent() { - this.editor?.commands.setContent(this.htmlContent, true) - }, - }, } diff --git a/src/components/CollisionResolveDialog.vue b/src/components/CollisionResolveDialog.vue index 7bdf0439b98..73e4bd978ac 100644 --- a/src/components/CollisionResolveDialog.vue +++ b/src/components/CollisionResolveDialog.vue @@ -29,17 +29,16 @@