Skip to content
Prev Previous commit
Next Next commit
enh(offline): disable file uploads when offline
Signed-off-by: Max <max@nextcloud.com>
  • Loading branch information
max-nextcloud committed Aug 17, 2025
commit 94bfd510e5451fc3b9154635eb7a1d7495a6c3a7
9 changes: 8 additions & 1 deletion src/apis/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

/**
Expand Down
26 changes: 16 additions & 10 deletions src/components/Menu/ActionAttachmentUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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: {
Expand Down
24 changes: 15 additions & 9 deletions src/components/SuggestionsBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
}
},

Expand All @@ -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 ''
},
},

Expand Down Expand Up @@ -204,6 +209,7 @@ export default {
const EMPTY_DOCUMENT_SIZE = 4
this.isEmptyContent = editor.state.doc.nodeSize <= EMPTY_DOCUMENT_SIZE
},
t,
},
}
</script>
Expand Down
14 changes: 11 additions & 3 deletions src/composables/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +30,10 @@ export const connectionKey = Symbol('text:connection') as InjectionKey<
ShallowRef<Connection | undefined>
>

export const openDataKey = Symbol('text:opendata') as InjectionKey<
ShallowRef<OpenData | undefined>
>

/**
* Handle the connection to the text api and provide it to child components
* @param props Props of the editor component.
Expand All @@ -46,6 +50,7 @@ export function provideConnection(props: {
}) {
const baseVersionEtag = shallowRef<string | undefined>(undefined)
const connection = shallowRef<Connection | undefined>(undefined)
const openData = shallowRef<OpenData | undefined>(undefined)
const openConnection = async () => {
const guestName = localStorage.getItem('nick') ?? ''
const { connection: opened, data } =
Expand All @@ -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 }
}

/**
Expand Down
9 changes: 1 addition & 8 deletions src/services/SessionConnection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,10 +60,6 @@ export class SessionConnection {
return this.closed
}

get hasOwner() {
return this.#hasOwner
}

get #defaultParams() {
return {
documentId: this.#document.id,
Expand Down
4 changes: 0 additions & 4 deletions src/services/SyncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 22 additions & 6 deletions src/tests/services/SyncService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,46 @@ 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)
await service.open()
expect(openHandler).toHaveBeenCalledWith(
expect.objectContaining({ session: initialData.session }),
)
expect(service.hasOwner).toBe(true)
expect(openData.value?.hasOwner).toBe(true)
})
})