diff --git a/cypress/e2e/share.spec.js b/cypress/e2e/share.spec.js index ddb4d6e8622..b494e3d2ffc 100644 --- a/cypress/e2e/share.spec.js +++ b/cypress/e2e/share.spec.js @@ -31,7 +31,7 @@ describe('Open test.md in viewer', function () { it('Shares the file as a public read only link', function () { cy.shareFile('/test.md') .then((token) => { - cy.logout() + cy.clearCookies() cy.visit(`/s/${token}`) }) .then(() => { @@ -71,7 +71,7 @@ describe('Open test.md in viewer', function () { it('Opens the editor as guest', function () { cy.shareFile('/test2.md', { edit: true }) .then((token) => { - cy.logout() + cy.clearCookies() cy.visit(`/s/${token}`) }) .then(() => { @@ -91,9 +91,8 @@ describe('Open test.md in viewer', function () { it('Shares a folder as a public read only link', function () { cy.shareFile('/folder') .then((token) => { - cy.logout() - - return cy.visit(`/s/${token}`) + cy.clearCookies() + cy.visit(`/s/${token}`) }) .then(() => { cy.openFile('test.md') @@ -112,7 +111,7 @@ describe('Open test.md in viewer', function () { it('Opens the editor as guest and set a session username', function () { cy.shareFile('/test3.md', { edit: true }) .then((token) => { - cy.logout() + cy.clearCookies() cy.visit(`/s/${token}`) }) .then(() => { @@ -159,7 +158,7 @@ describe('Open test.md in viewer', function () { url: '**/apps/text/public/session/*/create', }).as('create') cy.shareFile('/test2.md', { edit: true }).then((token) => { - cy.logout() + cy.clearCookies() cy.visit(`/s/${token}`) }) cy.wait('@create', { timeout: 10000 }) diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 6c94c54a8d0..57cdbe02df1 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -6,6 +6,7 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import type { Connection } from '../composables/useConnection.js' +import type { Document, Session } from '../services/SyncService.js' export interface OpenParams { fileId?: number @@ -16,7 +17,13 @@ export interface OpenParams { } export interface OpenData { - document: { baseVersionEtag: string } + document: Document + session: Session + readOnly: boolean + content: string + documentState?: string + lock?: object + hasOwner: boolean } /** @@ -43,6 +50,30 @@ export async function open( return { connection, data: response.data } } +/** + * Update the guest name + * @param guestName the name to use for the local user + * @param connection connection to close + */ +export async function update( + guestName: string, + connection: Connection, +): Promise { + if (!connection.shareToken) { + throw new Error('Cannot set guest name without a share token!') + } + const id = connection.documentId + const url = generateUrl(`/apps/text/public/session/${id}/session`) + const response = await axios.post(url, { + documentId: connection.documentId, + sessionId: connection.sessionId, + sessionToken: connection.sessionToken, + token: connection.shareToken, + guestName, + }) + return response.data +} + /** * Close the connection * @param connection connection to close diff --git a/src/apis/sync.ts b/src/apis/sync.ts index 2c944403f0a..d9f8f4cce33 100644 --- a/src/apis/sync.ts +++ b/src/apis/sync.ts @@ -6,8 +6,8 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { unref, type ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.js' -import type { Session, Step } from '../services/SyncService.js' +import type { Connection } from '../composables/useConnection.ts' +import type { Document, Session, Step } from '../services/SyncService.ts' interface PushData { version: number diff --git a/src/components/Editor.vue b/src/components/Editor.vue index a4e9840260e..d1f44bb7097 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -65,7 +65,7 @@ @@ -238,8 +238,7 @@ export default defineComponent({ isRichEditor, props, ) - const { connection, openConnection, baseVersionEtag } = - provideConnection(props) + const { connection, openConnection } = provideConnection(props) const { syncService } = provideSyncService(connection, openConnection) const extensions = [ Autofocus.configure({ fileId: props.fileId }), @@ -279,7 +278,6 @@ export default defineComponent({ return { awareness, - baseVersionEtag, editor, el, hasConnectionIssue, @@ -312,7 +310,6 @@ export default defineComponent({ filteredSessions: {}, idle: false, - lock: null, dirty: false, contentLoaded: false, syncError: null, @@ -542,16 +539,14 @@ export default defineComponent({ } }, - onOpened({ document, session, documentSource, documentState }) { + onOpened({ document, session, content, documentState, readOnly }) { this.currentSession = session this.document = document - this.readOnly = document.readOnly - this.baseVersionEtag = document.baseVersionEtag - this.editMode = !document.readOnly && !this.openReadOnlyEnabled + this.readOnly = readOnly + this.editMode = !readOnly && !this.openReadOnlyEnabled this.hasConnectionIssue = false this.setEditable(this.editMode) - this.lock = this.syncService.lock localStorage.setItem('nick', this.currentSession.guestName) this.$attachmentResolver = new AttachmentResolver({ session: this.currentSession, @@ -577,7 +572,7 @@ export default defineComponent({ this.lowlightLoaded.then(() => { this.syncService.startSync() if (!documentState) { - setInitialYjsState(this.ydoc, documentSource, { + setInitialYjsState(this.ydoc, content, { isRichEditor: this.isRichEditor, }) } diff --git a/src/components/Editor/GuestNameDialog.vue b/src/components/Editor/GuestNameDialog.vue index 1767f7e1c3a..b84c9694690 100644 --- a/src/components/Editor/GuestNameDialog.vue +++ b/src/components/Editor/GuestNameDialog.vue @@ -23,9 +23,11 @@ + + diff --git a/src/components/Editor/SessionList.vue b/src/components/Editor/SessionList.vue index 1793d570494..f19f7c7f445 100644 --- a/src/components/Editor/SessionList.vue +++ b/src/components/Editor/SessionList.vue @@ -5,7 +5,7 @@ @@ -36,11 +36,12 @@ import { t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import NcButton from '@nextcloud/vue/components/NcButton' import NcSavingIndicatorIcon from '@nextcloud/vue/components/NcSavingIndicatorIcon' -import { useEditorFlags } from '../../composables/useEditorFlags.ts' +import { useNetworkState } from '../../composables/useNetworkState.ts' import { useSaveService } from '../../composables/useSaveService.ts' import refreshMoment from '../../mixins/refreshMoment.js' import { ERROR_TYPE } from '../../services/SyncService.ts' import { useIsMobileMixin } from '../Editor.provider.ts' +import OfflineState from './OfflineState.vue' export default { name: 'Status', @@ -48,10 +49,9 @@ export default { components: { NcButton, NcSavingIndicatorIcon, + OfflineState, SessionList: () => import(/* webpackChunkName: "editor-collab" */ './SessionList.vue'), - GuestNameDialog: () => - import(/* webpackChunkName: "editor-guest" */ './GuestNameDialog.vue'), }, mixins: [useIsMobileMixin, refreshMoment], @@ -82,9 +82,9 @@ export default { }, setup() { - const { isPublic } = useEditorFlags() + const { networkOnline, offlineSince } = useNetworkState() const { saveService } = useSaveService() - return { isPublic, saveService } + return { networkOnline, offlineSince, saveService } }, computed: { @@ -123,14 +123,14 @@ export default { ) }, saveStatusClass() { - if (this.syncError && this.lastSavedString !== '') { + if ( + (this.dirtyStateIndicator && !this.networkOnline) + || (this.syncError && this.lastSavedString !== '') + ) { return 'error' } return this.dirtyStateIndicator ? 'saving' : 'saved' }, - currentSession() { - return Object.values(this.sessions).find((session) => session.isCurrent) - }, lastSavedString() { // Make this a dependent of refreshMoment, so it will be recomputed /* eslint-disable-next-line no-unused-expressions */ diff --git a/src/components/Menu/ActionAttachmentUpload.vue b/src/components/Menu/ActionAttachmentUpload.vue index 7f12935dca4..84401588359 100644 --- a/src/components/Menu/ActionAttachmentUpload.vue +++ b/src/components/Menu/ActionAttachmentUpload.vue @@ -67,8 +67,9 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcActions from '@nextcloud/vue/components/NcActions' import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import { useConnection } from '../../composables/useConnection.ts' import { useEditorFlags } from '../../composables/useEditorFlags.ts' -import { useSyncService } from '../../composables/useSyncService.ts' +import { useNetworkState } from '../../composables/useNetworkState.ts' import { useEditorUpload } from '../Editor.provider.ts' import { useActionAttachmentPromptMixin, @@ -103,8 +104,9 @@ export default { ], setup() { const { isPublic } = useEditorFlags() - const { syncService } = useSyncService() - return { ...BaseActionEntry.setup(), isPublic, syncService } + const { openData } = useConnection() + const { networkOnline } = useNetworkState() + return { ...BaseActionEntry.setup(), isPublic, networkOnline, openData } }, computed: { icon() { @@ -117,15 +119,19 @@ export default { return loadState('files', 'templates', []) }, isUploadDisabled() { - return !this.syncService.hasOwner + return !this.openData?.hasOwner || !this.networkOnline }, menuTitle() { - return this.isUploadDisabled - ? t( - 'text', - 'Attachments cannot be created or uploaded because this file is shared from another cloud.', - ) - : this.actionEntry.label + if (!this.networkOnline) { + return t('text', 'Disabled because you are currently offline.') + } + if (!this.openData?.hasOwner) { + return t( + 'text', + 'Attachments cannot be created or uploaded because this file is shared from another cloud.', + ) + } + return this.actionEntry.label }, }, methods: { diff --git a/src/components/SuggestionsBar.vue b/src/components/SuggestionsBar.vue index 8487db17d71..23cc50e897c 100644 --- a/src/components/SuggestionsBar.vue +++ b/src/components/SuggestionsBar.vue @@ -62,8 +62,9 @@ import { generateUrl } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js' import { Document, Shape, Table as TableIcon, Upload } from '../components/icons.js' +import { useConnection } from '../composables/useConnection.ts' import { useEditor } from '../composables/useEditor.ts' -import { useSyncService } from '../composables/useSyncService.ts' +import { useNetworkState } from '../composables/useNetworkState.ts' import { buildFilePicker } from '../helpers/filePicker.js' import { isMobileDevice } from '../helpers/isMobileDevice.js' import { useFileMixin } from './Editor.provider.ts' @@ -83,12 +84,13 @@ export default { setup() { const { editor } = useEditor() - const { syncService } = useSyncService() + const { openData } = useConnection() + const { networkOnline } = useNetworkState() return { editor, isMobileDevice, - syncService, - t, + networkOnline, + openData, } }, @@ -104,16 +106,19 @@ export default { return this.$file?.relativePath ?? '/' }, isUploadDisabled() { - return !this.syncService.hasOwner + return !this.openData?.hasOwner || !this.networkOnline }, uploadTitle() { - return ( - this.isUploadDisabled - && t( + if (!this.networkOnline) { + return t('text', 'Disabled because you are currently offline.') + } + if (this.isUploadDisabled) { + return t( 'text', 'Uploading attachments is disabled because the file is shared from another cloud.', ) - ) + } + return '' }, }, @@ -204,6 +209,7 @@ export default { const EMPTY_DOCUMENT_SIZE = 4 this.isEmptyContent = editor.state.doc.nodeSize <= EMPTY_DOCUMENT_SIZE }, + t, }, } diff --git a/src/composables/useConnection.ts b/src/composables/useConnection.ts index 0e1ae9beeaa..a18e854acb4 100644 --- a/src/composables/useConnection.ts +++ b/src/composables/useConnection.ts @@ -4,7 +4,7 @@ */ import { inject, provide, shallowRef, type InjectionKey, type ShallowRef } from 'vue' -import { open } from '../apis/connect' +import { open, type OpenData } from '../apis/connect' import type { Document, Session } from '../services/SyncService.js' export interface Connection { @@ -30,6 +30,10 @@ export const connectionKey = Symbol('text:connection') as InjectionKey< ShallowRef > +export const openDataKey = Symbol('text:opendata') as InjectionKey< + ShallowRef +> + /** * Handle the connection to the text api and provide it to child components * @param props Props of the editor component. @@ -44,8 +48,9 @@ export function provideConnection(props: { initialSession?: InitialData shareToken?: string }) { - const baseVersionEtag = shallowRef(undefined) + let baseVersionEtag: string | undefined const connection = shallowRef(undefined) + const openData = shallowRef(undefined) const openConnection = async () => { const guestName = localStorage.getItem('nick') ?? '' const { connection: opened, data } = @@ -55,19 +60,22 @@ export function provideConnection(props: { guestName, token: props.shareToken, filePath: props.relativePath, - baseVersionEtag: baseVersionEtag.value, + baseVersionEtag, })) - baseVersionEtag.value = data.document.baseVersionEtag + baseVersionEtag = data.document.baseVersionEtag connection.value = opened + openData.value = data return data } provide(connectionKey, connection) - return { connection, openConnection, baseVersionEtag } + provide(openDataKey, openData) + return { connection, openConnection, openData } } export const useConnection = () => { const connection = inject(connectionKey) - return { connection } + const openData = inject(openDataKey) + return { connection, openData } } /** diff --git a/src/composables/useNetworkState.ts b/src/composables/useNetworkState.ts new file mode 100644 index 00000000000..789955397fa --- /dev/null +++ b/src/composables/useNetworkState.ts @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { subscribe } from '@nextcloud/event-bus' +import { computed, ref } from 'vue' + +declare module '@nextcloud/event-bus' { + export interface NextcloudEvents { + networkOnline: { success: boolean } + } +} + +/** + * Get network online/offline state + */ +export function useNetworkState() { + const offlineSince = ref(navigator.onLine ? null : Date.now()) + const networkOnline = computed(() => !offlineSince.value) + + subscribe('networkOnline', (event) => { + if (event.success) { + offlineSince.value = null + } + }) + subscribe('networkOffline', () => { + offlineSince.value = Date.now() + }) + + return { networkOnline, offlineSince } +} diff --git a/src/services/PollingBackend.ts b/src/services/PollingBackend.ts index ef02dc53ae6..753a70e6796 100644 --- a/src/services/PollingBackend.ts +++ b/src/services/PollingBackend.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { Emitter } from 'mitt' +import type { OpenData } from '../apis/connect.js' import { sync } from '../apis/sync' import type { Connection } from '../composables/useConnection.js' import { logger } from '../helpers/logger.js' import getNotifyBus, { type EventTypes } from './NotifyService' import { + type Document, + ERROR_TYPE, type Session, type Step, type SyncService, - ERROR_TYPE, } from './SyncService.js' /** @@ -67,6 +69,7 @@ interface ConflictData extends PollData { class PollingBackend { #syncService: SyncService #connection: Connection + #readOnly: boolean #lastPoll #fetchInterval: number @@ -83,12 +86,17 @@ class PollingBackend { } } - constructor(syncService: SyncService, connection: Connection) { + constructor( + syncService: SyncService, + connection: Connection, + { readOnly }: OpenData, + ) { this.#syncService = syncService this.#connection = connection this.#fetchInterval = FETCH_INTERVAL this.#fetchRetryCounter = 0 this.#lastPoll = 0 + this.#readOnly = readOnly } connect() { @@ -149,7 +157,7 @@ class PollingBackend { } const disconnect = Date.now() - COLLABORATOR_DISCONNECT_TIME const alive = sessions.filter((s) => s.lastContact * 1000 > disconnect) - if (this.#syncService.isReadOnly) { + if (this.#readOnly) { this.maximumReadOnlyTimer() } else if (alive.length < 2) { this.maximumRefetchTimer() diff --git a/src/services/SessionConnection.js b/src/services/SessionConnection.js deleted file mode 100644 index f07a9d67196..00000000000 --- a/src/services/SessionConnection.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * 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' - -export class ConnectionClosedError extends Error { - constructor( - message = 'Close has already been called on the connection', - ...rest - ) { - super(message, ...rest) - } -} - -export class SessionConnection { - #content - closed - #documentState - #document - #session - #readOnly - #hasOwner - connection - - constructor(data, connection) { - const { document, session, readOnly, content, documentState, hasOwner } = - data - this.#document = document - this.#session = session - this.#readOnly = readOnly - this.#content = content - this.#documentState = documentState - this.#hasOwner = hasOwner - this.connection = connection - this.isPublic = !!connection.shareToken - this.closed = false - } - - get session() { - return this.#session - } - - get document() { - return this.#document - } - - get docStateVersion() { - return this.#documentState ? this.#document.lastSavedVersion : 0 - } - - get state() { - return { - document: { ...this.#document, readOnly: this.#readOnly }, - session: this.#session, - documentSource: this.#content || '', - documentState: this.#documentState, - } - } - - get isClosed() { - return this.closed - } - - get hasOwner() { - return this.#hasOwner - } - - get #defaultParams() { - return { - documentId: this.#document.id, - sessionId: this.#session.id, - sessionToken: this.#session.token, - token: this.connection.shareToken, - } - } - - // TODO: maybe return a new connection here so connections have immutable state - update(guestName) { - return this.#post(this.#url(`session/${this.#document.id}/session`), { - ...this.#defaultParams, - guestName, - }).then(({ data }) => { - this.#session = data - }) - } - - close() { - this.closed = true - } - - // To be used in Cypress tests only - setBaseVersionEtag(baseVersionEtag) { - this.#document.baseVersionEtag = baseVersionEtag - } - - #post(...args) { - if (this.closed) { - return Promise.reject(new ConnectionClosedError()) - } - return axios.post(...args) - } - - #url(endpoint) { - const isPublic = !!this.#defaultParams.token - return _endpointUrl(endpoint, isPublic) - } -} - -/** - * - * @param {string} endpoint - endpoint of the url inside apps/text - * @param {boolean} isPublic - public url or not - */ -function _endpointUrl(endpoint, isPublic = false) { - const _baseUrl = generateUrl('/apps/text') - if (isPublic) { - return `${_baseUrl}/public/${endpoint}` - } - return `${_baseUrl}/${endpoint}` -} diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 1e8ea66a19d..df461572287 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -15,7 +15,6 @@ import { logger } from '../helpers/logger.js' import { documentStateToStep } from '../helpers/yjs.js' import Outbox from './Outbox.js' import PollingBackend from './PollingBackend.js' -import { SessionConnection } from './SessionConnection.js' /** * Timeout after which the editor will consider a document without changes being synced as idle @@ -81,12 +80,7 @@ export interface Document { export declare type EventTypes = { /* Document state */ - opened: { - document: Document - session: Session - documentSource: string - documentState: string - } + opened: OpenData /* All initial steps fetched */ fetched: unknown @@ -115,7 +109,6 @@ export declare type EventTypes = { class SyncService { connection: ShallowRef - sessionConnection?: SessionConnection version = -1 pushError = 0 backend?: PollingBackend @@ -137,23 +130,10 @@ class SyncService { this.#openConnection = openConnection } - get isReadOnly() { - return this.sessionConnection?.state.document.readOnly - } - - get hasOwner() { - return this.sessionConnection?.hasOwner - } - - get guestName() { - return this.sessionConnection?.session.guestName - } - hasActiveConnection(): this is { - sessionConnection: SessionConnection connection: ShallowRef } { - return !!this.sessionConnection && !this.sessionConnection.isClosed + return Boolean(this.connection.value) } async open() { @@ -165,15 +145,14 @@ class SyncService { // Error was already emitted above return } - this.sessionConnection = new SessionConnection(data, this.connection.value) - this.version = this.sessionConnection.docStateVersion if (!this.connection.value) { console.error('Opened the connection but now it is undefined') return } - this.backend = new PollingBackend(this, this.connection.value) + this.version = data.document.lastSavedVersion + this.backend = new PollingBackend(this, this.connection.value, data) // Make sure to only emit this once the backend is in place. - this.emit('opened', this.sessionConnection.state) + this.emit('opened', data) } startSync() { @@ -192,16 +171,6 @@ class SyncService { } } - updateSession(guestName: string) { - if (!this.sessionConnection?.isPublic) { - return Promise.reject(new Error()) - } - return this.sessionConnection.update(guestName).catch((error) => { - logger.error('Failed to update the session', { error }) - return Promise.reject(error) - }) - } - sendStep(step: ArrayBuffer) { this.#outbox.storeStep(step) this.sendSteps() @@ -213,7 +182,7 @@ class SyncService { return } this.#sendIntervalId = setInterval(() => { - if (this.sessionConnection && !this.#sending) { + if (this.connection.value && !this.#sending) { this.sendStepsNow().catch((err) => logger.error(err)) } }, 200) @@ -245,7 +214,6 @@ class SyncService { this.emit('sync', { version: this.version, steps: [documentStateStep], - document: this.sessionConnection.document, }) } this.pushError = 0 @@ -355,15 +323,15 @@ class SyncService { async close() { this.backend?.disconnect() - if (this.connection.value) { + if (this.hasActiveConnection()) { close(this.connection.value) // Log and ignore possible network issues. .catch((e) => { logger.info('Failed to close connection.', { e }) }) } - // Mark sessionConnection closed so hasActiveConnection turns false and we can reconnect. - this.sessionConnection?.close() + // Clear connection so hasActiveConnection turns false and we can reconnect. + this.connection.value = undefined this.emit('close') } diff --git a/src/tests/services/SyncService.spec.ts b/src/tests/services/SyncService.spec.ts index 1022a3d18d4..e680f0111e8 100644 --- a/src/tests/services/SyncService.spec.ts +++ b/src/tests/services/SyncService.spec.ts @@ -16,23 +16,39 @@ const connection = { baseVersionEtag: 'etag', } const initialData = { - session: { id: 345 }, - document: { id: 123, baseVersionEtag: 'etag' }, + session: { + id: 345, + userId: 'me', + token: 'shareToken', + color: '#abcabc', + lastContact: Date.now(), + documentId: 123, + displayName: 'My Name', + lastAwarenessMessage: 'hi', + clientId: 1, + }, + document: { + id: 123, + baseVersionEtag: 'etag', + initialVersion: 0, + lastSavedVersion: 345, + lastSavedVersionTime: Date.now(), + }, readOnly: false, content: '', hasOwner: true, } -const openData = { connection, data: initialData } +const openResult = { connection, data: initialData } describe('Sync service', () => { it('opens a connection', async () => { - const { connection, openConnection } = provideConnection({ + const { connection, openConnection, openData } = provideConnection({ fileId: 123, relativePath: './', }) vi.mock('../../apis/connect') - vi.mocked(connect.open).mockResolvedValue(openData) + vi.mocked(connect.open).mockResolvedValue(openResult) const openHandler = vi.fn() const service = new SyncService({ connection, openConnection }) service.on('opened', openHandler) @@ -40,6 +56,6 @@ describe('Sync service', () => { expect(openHandler).toHaveBeenCalledWith( expect.objectContaining({ session: initialData.session }), ) - expect(service.hasOwner).toBe(true) + expect(openData.value?.hasOwner).toBe(true) }) })