From 60eb9023408221006d57262550fe0b2a451b0e1e Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 8 Jul 2025 16:38:55 +0200 Subject: [PATCH 1/9] feat(useNetworkState): composable to get network online/offline state Signed-off-by: Jonas --- src/composables/useNetworkState.ts | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/composables/useNetworkState.ts diff --git a/src/composables/useNetworkState.ts b/src/composables/useNetworkState.ts new file mode 100644 index 00000000000..6cee50ea995 --- /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 } +} From c01ddd0dbdee289ffd19f7fabca9c8f63aa114da Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 8 Jul 2025 16:50:25 +0200 Subject: [PATCH 2/9] feat(status): Display offline state instead of session list Fixes: #7282 Signed-off-by: Jonas --- src/components/Editor/OfflineState.vue | 73 ++++++++++++++++++++++++++ src/components/Editor/Status.vue | 20 +++++-- src/composables/useNetworkState.ts | 2 +- 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/components/Editor/OfflineState.vue diff --git a/src/components/Editor/OfflineState.vue b/src/components/Editor/OfflineState.vue new file mode 100644 index 00000000000..bd7220d8084 --- /dev/null +++ b/src/components/Editor/OfflineState.vue @@ -0,0 +1,73 @@ + + + + + + + diff --git a/src/components/Editor/Status.vue b/src/components/Editor/Status.vue index a92503c87b4..fafcdc150cc 100644 --- a/src/components/Editor/Status.vue +++ b/src/components/Editor/Status.vue @@ -20,7 +20,9 @@ - +

{{ t('text', 'Last saved') }}: {{ lastSavedString }}

@@ -28,6 +30,7 @@ v-if="isPublic && currentSession && !currentSession.userId" :session="currentSession" />
+ @@ -37,21 +40,24 @@ 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', components: { + GuestNameDialog: () => + import(/* webpackChunkName: "editor-guest" */ './GuestNameDialog.vue'), NcButton, NcSavingIndicatorIcon, + OfflineState, SessionList: () => import(/* webpackChunkName: "editor-collab" */ './SessionList.vue'), - GuestNameDialog: () => - import(/* webpackChunkName: "editor-guest" */ './GuestNameDialog.vue'), }, mixins: [useIsMobileMixin, refreshMoment], @@ -83,8 +89,9 @@ export default { setup() { const { isPublic } = useEditorFlags() + const { networkOnline, offlineSince } = useNetworkState() const { saveService } = useSaveService() - return { isPublic, saveService } + return { isPublic, networkOnline, offlineSince, saveService } }, computed: { @@ -123,7 +130,10 @@ export default { ) }, saveStatusClass() { - if (this.syncError && this.lastSavedString !== '') { + if ( + (this.dirtyStateIndicator && !this.networkOnline) + || (this.syncError && this.lastSavedString !== '') + ) { return 'error' } return this.dirtyStateIndicator ? 'saving' : 'saved' diff --git a/src/composables/useNetworkState.ts b/src/composables/useNetworkState.ts index 6cee50ea995..789955397fa 100644 --- a/src/composables/useNetworkState.ts +++ b/src/composables/useNetworkState.ts @@ -8,7 +8,7 @@ import { computed, ref } from 'vue' declare module '@nextcloud/event-bus' { export interface NextcloudEvents { - 'networkOnline': { success: boolean } + networkOnline: { success: boolean } } } From 94bfd510e5451fc3b9154635eb7a1d7495a6c3a7 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 8 Jul 2025 19:12:27 +0200 Subject: [PATCH 3/9] enh(offline): disable file uploads when offline Signed-off-by: Max --- src/apis/connect.ts | 9 +++++- .../Menu/ActionAttachmentUpload.vue | 26 ++++++++++------- src/components/SuggestionsBar.vue | 24 ++++++++++------ src/composables/useConnection.ts | 14 ++++++++-- src/services/SessionConnection.js | 9 +----- src/services/SyncService.ts | 4 --- src/tests/services/SyncService.spec.ts | 28 +++++++++++++++---- 7 files changed, 73 insertions(+), 41 deletions(-) diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 6c94c54a8d0..8c9a232e0ea 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 } /** 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..5312ad49d49 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. @@ -46,6 +50,7 @@ export function provideConnection(props: { }) { const baseVersionEtag = shallowRef(undefined) const connection = shallowRef(undefined) + const openData = shallowRef(undefined) const openConnection = async () => { const guestName = localStorage.getItem('nick') ?? '' const { connection: opened, data } = @@ -59,15 +64,18 @@ export function provideConnection(props: { })) baseVersionEtag.value = 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/services/SessionConnection.js b/src/services/SessionConnection.js index f07a9d67196..82eaf8b9e63 100644 --- a/src/services/SessionConnection.js +++ b/src/services/SessionConnection.js @@ -21,18 +21,15 @@ export class SessionConnection { #document #session #readOnly - #hasOwner connection constructor(data, connection) { - const { document, session, readOnly, content, documentState, hasOwner } = - data + const { document, session, readOnly, content, documentState } = 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 @@ -63,10 +60,6 @@ export class SessionConnection { return this.closed } - get hasOwner() { - return this.#hasOwner - } - get #defaultParams() { return { documentId: this.#document.id, diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 1e8ea66a19d..275562ae48e 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -141,10 +141,6 @@ class SyncService { return this.sessionConnection?.state.document.readOnly } - get hasOwner() { - return this.sessionConnection?.hasOwner - } - get guestName() { return this.sessionConnection?.session.guestName } 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) }) }) From b8cb159ff1e75d0203b4bbf6a4ec5964d9aed73f Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 14 Aug 2025 11:30:51 +0200 Subject: [PATCH 4/9] chore(refactor): use GuestNameDialog directly in SessionList Signed-off-by: Max --- src/components/Editor/SessionList.vue | 18 ++++++++++++++++-- src/components/Editor/Status.vue | 12 +----------- 2 files changed, 17 insertions(+), 13 deletions(-) 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 @@