diff --git a/changelog/unreleased/enhancement-add-sse-event-for-file-creation b/changelog/unreleased/enhancement-add-sse-event-for-file-creation new file mode 100644 index 00000000000..ed5503b5ed8 --- /dev/null +++ b/changelog/unreleased/enhancement-add-sse-event-for-file-creation @@ -0,0 +1,9 @@ +Enhancement: Implement Server-Sent Events (SSE) for File Creation + +We've implemented Server-Sent Events (SSE) to notify users in real-time when a file is uploaded, +a new folder is created, or a file is created (e.g., a text file). +With this enhancement, users will see new files automatically appear in another browser tab if they have one open or +when collaborating with others in the same space. + +https://github.com/owncloud/web/pull/10709 +https://github.com/owncloud/web/issues/9782 diff --git a/packages/web-client/src/sse.ts b/packages/web-client/src/sse.ts index f5d2c0a19dc..7b851e6d840 100644 --- a/packages/web-client/src/sse.ts +++ b/packages/web-client/src/sse.ts @@ -5,9 +5,11 @@ export enum MESSAGE_TYPE { POSTPROCESSING_FINISHED = 'postprocessing-finished', FILE_LOCKED = 'file-locked', FILE_UNLOCKED = 'file-unlocked', + FILE_TOUCHED = 'file-touched', ITEM_RENAMED = 'item-renamed', ITEM_TRASHED = 'item-trashed', - ITEM_RESTORED = 'item-restored' + ITEM_RESTORED = 'item-restored', + FOLDER_CREATED = 'folder-created' } export class RetriableError extends Error { diff --git a/packages/web-client/src/webdav/client/dav.ts b/packages/web-client/src/webdav/client/dav.ts index d50950d1190..312ee9fb986 100644 --- a/packages/web-client/src/webdav/client/dav.ts +++ b/packages/web-client/src/webdav/client/dav.ts @@ -18,6 +18,7 @@ interface DAVOptions { accessToken: Ref baseUrl: string language: Ref + clientInitiatorId: Ref } interface DavResult { @@ -31,12 +32,14 @@ export class DAV { private client: WebDAVClient private davPath: string private language: Ref + private clientInitiatorId: Ref - constructor({ accessToken, baseUrl, language }: DAVOptions) { + constructor({ accessToken, baseUrl, language, clientInitiatorId }: DAVOptions) { this.davPath = urlJoin(baseUrl, 'remote.php/dav') this.accessToken = accessToken this.client = createClient(this.davPath, {}) this.language = language + this.clientInitiatorId = clientInitiatorId } public mkcol(path: string, { headers = {} }: { headers?: Headers } = {}) { @@ -166,6 +169,7 @@ export class DAV { return { 'Accept-Language': unref(this.language), 'Content-Type': 'application/xml; charset=utf-8', + 'Initiator-ID': unref(this.clientInitiatorId), 'X-Requested-With': 'XMLHttpRequest', 'X-Request-ID': uuidV4(), ...headers diff --git a/packages/web-client/src/webdav/index.ts b/packages/web-client/src/webdav/index.ts index b6c280e9f7c..c70c3d8718d 100644 --- a/packages/web-client/src/webdav/index.ts +++ b/packages/web-client/src/webdav/index.ts @@ -27,7 +27,8 @@ export const webdav = (options: WebDavOptions): WebDAV => { const dav = new DAV({ accessToken: options.accessToken, baseUrl: options.baseUrl, - language: options.language + language: options.language, + clientInitiatorId: options.clientInitiatorId }) const pathForFileIdFactory = GetPathForFileIdFactory(dav, options) diff --git a/packages/web-client/src/webdav/types.ts b/packages/web-client/src/webdav/types.ts index ba002e4e845..8a47b3bd53b 100644 --- a/packages/web-client/src/webdav/types.ts +++ b/packages/web-client/src/webdav/types.ts @@ -28,6 +28,7 @@ export interface WebDavOptions { clientService: any language: Ref user: Ref + clientInitiatorId: Ref } export interface WebDAV { diff --git a/packages/web-pkg/src/components/FilesList/ResourceTile.vue b/packages/web-pkg/src/components/FilesList/ResourceTile.vue index ff1effe5885..5d301452f07 100644 --- a/packages/web-pkg/src/components/FilesList/ResourceTile.vue +++ b/packages/web-pkg/src/components/FilesList/ResourceTile.vue @@ -33,7 +33,11 @@ >
- + { + return resource.thumbnail && !isResourceTxtFileAlmostEmpty(resource) + } + return { statusIconAttrs, showStatusIcon, tooltipLabelIcon, resourceDisabled, - resourceDescription + resourceDescription, + shouldDisplayThumbnails } } }) diff --git a/packages/web-pkg/src/composables/piniaStores/client.ts b/packages/web-pkg/src/composables/piniaStores/client.ts new file mode 100644 index 00000000000..556b52cf323 --- /dev/null +++ b/packages/web-pkg/src/composables/piniaStores/client.ts @@ -0,0 +1,16 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useClientStore = defineStore('client', () => { + const clientInitiatorId = ref() + const setClientInitiatorId = (id: string) => { + clientInitiatorId.value = id + } + + return { + clientInitiatorId, + setClientInitiatorId + } +}) + +export type ClientStore = ReturnType diff --git a/packages/web-pkg/src/composables/piniaStores/index.ts b/packages/web-pkg/src/composables/piniaStores/index.ts index 371b95322bc..358d3b2213c 100644 --- a/packages/web-pkg/src/composables/piniaStores/index.ts +++ b/packages/web-pkg/src/composables/piniaStores/index.ts @@ -11,3 +11,4 @@ export * from './shares' export * from './spaces' export * from './theme' export * from './user' +export * from './client' diff --git a/packages/web-pkg/src/composables/upload/useUpload.ts b/packages/web-pkg/src/composables/upload/useUpload.ts index e068936ffff..f47c3df30bc 100644 --- a/packages/web-pkg/src/composables/upload/useUpload.ts +++ b/packages/web-pkg/src/composables/upload/useUpload.ts @@ -2,7 +2,7 @@ import { computed, unref, watch } from 'vue' import { UppyService } from '../../services/uppy/uppyService' import { v4 as uuidV4 } from 'uuid' import { useGettext } from 'vue3-gettext' -import { useAuthStore, useCapabilityStore, useConfigStore } from '../piniaStores' +import { useAuthStore, useCapabilityStore, useClientStore, useConfigStore } from '../piniaStores' interface UploadOptions { uppyService: UppyService @@ -11,11 +11,15 @@ interface UploadOptions { export function useUpload(options: UploadOptions) { const configStore = useConfigStore() const capabilityStore = useCapabilityStore() + const clientStore = useClientStore() const { current: currentLanguage } = useGettext() const authStore = useAuthStore() const headers = computed((): { [key: string]: string } => { - const headers = { 'Accept-Language': currentLanguage } + const headers = { + 'Accept-Language': currentLanguage, + 'Initiator-ID': clientStore.clientInitiatorId + } if (authStore.publicLinkContextReady) { const password = authStore.publicLinkPassword if (password) { @@ -42,6 +46,7 @@ export function useUpload(options: UploadOptions) { req.setHeader('Authorization', unref(headers).Authorization) req.setHeader('X-Request-ID', uuidV4()) req.setHeader('Accept-Language', unref(headers)['Accept-Language']) + req.setHeader('Initiator-ID', unref(headers)['Initiator-ID']) }, headers: (file) => !!file.xhrUpload || file?.isRemote diff --git a/packages/web-pkg/src/services/client/client.ts b/packages/web-pkg/src/services/client/client.ts index e77a2155ab8..d786aa23604 100644 --- a/packages/web-pkg/src/services/client/client.ts +++ b/packages/web-pkg/src/services/client/client.ts @@ -7,7 +7,7 @@ import { WebDAV } from '@ownclouders/web-client/src/webdav' import { Language } from 'vue3-gettext' import { FetchEventSourceInit } from '@microsoft/fetch-event-source' import { sse } from '@ownclouders/web-client/src/sse' -import { AuthStore, ConfigStore, UserStore } from '../../composables' +import { AuthStore, ClientStore, ConfigStore, UserStore } from '../../composables' import { computed } from 'vue' interface OcClient { @@ -54,6 +54,7 @@ export interface ClientServiceOptions { language: Language authStore: AuthStore userStore: UserStore + clientStore: ClientStore } export class ClientService { @@ -61,6 +62,7 @@ export class ClientService { private language: Language private authStore: AuthStore private userStore: UserStore + private clientStore: ClientStore private httpAuthenticatedClient: HttpClient private httpUnAuthenticatedClient: HttpClient @@ -75,6 +77,7 @@ export class ClientService { this.language = options.language this.authStore = options.authStore this.userStore = options.userStore + this.clientStore = options.clientStore } public get httpAuthenticated(): _HttpClient { @@ -132,7 +135,8 @@ export class ClientService { 'Accept-Language': this.currentLanguage, ...(!!authenticated && { Authorization: 'Bearer ' + this.authStore.accessToken }), 'X-Requested-With': 'XMLHttpRequest', - 'X-Request-ID': uuidV4() + 'X-Request-ID': uuidV4(), + 'Initiator-ID': this.clientStore.clientInitiatorId } }) } diff --git a/packages/web-pkg/tests/unit/services/client.spec.ts b/packages/web-pkg/tests/unit/services/client.spec.ts index 1b9686d37f9..e7ef248fa7c 100644 --- a/packages/web-pkg/tests/unit/services/client.spec.ts +++ b/packages/web-pkg/tests/unit/services/client.spec.ts @@ -1,5 +1,11 @@ import { HttpClient } from '../../../src/http' -import { ClientService, useAuthStore, useConfigStore, useUserStore } from '../../../src/' +import { + ClientService, + useAuthStore, + useClientStore, + useConfigStore, + useUserStore +} from '../../../src/' import { Language } from 'vue3-gettext' import { Graph, OCS, client as _client } from '@ownclouders/web-client' import { createTestingPinia, writable } from 'web-test-helpers' @@ -13,6 +19,7 @@ const getClientServiceMock = () => { const authStore = useAuthStore() const configStore = useConfigStore() const userStore = useUserStore() + const clientStore = useClientStore() writable(configStore).serverUrl = serverUrl return { @@ -20,7 +27,8 @@ const getClientServiceMock = () => { configStore, language: language as Language, authStore, - userStore + userStore, + clientStore }), authStore } diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 4544d7a5224..65f2b310d51 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -27,7 +27,8 @@ import { useResourcesStore, ResourcesStore, SpacesStore, - MessageStore + MessageStore, + useClientStore } from '@ownclouders/web-pkg' import { authService } from '../services/auth' import { @@ -35,7 +36,8 @@ import { LoadingService, PasswordPolicyService, PreviewService, - UppyService + UppyService, + ClientStore } from '@ownclouders/web-pkg' import { init as sentryInit } from '@sentry/vue' import { webdav } from '@ownclouders/web-client/src/webdav' @@ -60,7 +62,9 @@ import { onSSEItemRenamedEvent, onSSEProcessingFinishedEvent, onSSEItemRestoredEvent, - onSSEItemTrashedEvent + onSSEItemTrashedEvent, + onSSEFolderCreatedEvent, + onSSEFileTouchedEvent } from './sse' const getEmbedConfigFromQuery = ( @@ -331,6 +335,7 @@ export const announcePiniaStores = () => { const sharesStore = useSharesStore() const spacesStore = useSpacesStore() const userStore = useUserStore() + const clientStore = useClientStore() return { appsStore, @@ -343,7 +348,8 @@ export const announcePiniaStores = () => { modalStore, sharesStore, spacesStore, - userStore + userStore, + clientStore } } @@ -388,19 +394,24 @@ export const announceClientService = ({ configStore, userStore, authStore, - capabilityStore + capabilityStore, + clientStore }: { app: App configStore: ConfigStore userStore: UserStore authStore: AuthStore capabilityStore: CapabilityStore + clientStore: ClientStore }): void => { + clientStore.setClientInitiatorId(uuidV4()) + const clientService = new ClientService({ configStore, language: app.config.globalProperties.$language, authStore, - userStore + userStore, + clientStore }) app.config.globalProperties.$clientService = clientService app.config.globalProperties.$clientService.webdav = webdav({ @@ -409,6 +420,7 @@ export const announceClientService = ({ capabilities: computed(() => capabilityStore.capabilities), clientService: app.config.globalProperties.$clientService, language: computed(() => app.config.globalProperties.$language.current), + clientInitiatorId: computed(() => clientStore.clientInitiatorId), user: computed(() => userStore.user) }) @@ -644,6 +656,7 @@ export const registerSSEEventListeners = ({ language, resourcesStore, spacesStore, + clientStore, messageStore, clientService, previewService, @@ -653,6 +666,7 @@ export const registerSSEEventListeners = ({ language: Language resourcesStore: ResourcesStore spacesStore: SpacesStore + clientStore: ClientStore messageStore: MessageStore clientService: ClientService previewService: PreviewService @@ -675,6 +689,7 @@ export const registerSSEEventListeners = ({ topic: MESSAGE_TYPE.ITEM_RENAMED, resourcesStore, spacesStore, + clientStore, msg, clientService, router @@ -686,6 +701,7 @@ export const registerSSEEventListeners = ({ topic: MESSAGE_TYPE.POSTPROCESSING_FINISHED, resourcesStore, spacesStore, + clientStore, msg, clientService, previewService, @@ -718,6 +734,7 @@ export const registerSSEEventListeners = ({ topic: MESSAGE_TYPE.ITEM_TRASHED, language, resourcesStore, + clientStore, messageStore, msg }) @@ -728,6 +745,29 @@ export const registerSSEEventListeners = ({ topic: MESSAGE_TYPE.ITEM_RESTORED, resourcesStore, spacesStore, + clientStore, + msg, + clientService + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FOLDER_CREATED, (msg) => + onSSEFolderCreatedEvent({ + topic: MESSAGE_TYPE.FOLDER_CREATED, + resourcesStore, + spacesStore, + clientStore, + msg, + clientService + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FILE_TOUCHED, (msg) => + onSSEFileTouchedEvent({ + topic: MESSAGE_TYPE.FILE_TOUCHED, + resourcesStore, + spacesStore, + clientStore, msg, clientService }) diff --git a/packages/web-runtime/src/container/sse.ts b/packages/web-runtime/src/container/sse.ts index 6d18c05723f..5b08fc7e9e5 100644 --- a/packages/web-runtime/src/container/sse.ts +++ b/packages/web-runtime/src/container/sse.ts @@ -1,5 +1,6 @@ import { ClientService, + ClientStore, createFileRouteOptions, getIndicators, ImageDimension, @@ -17,7 +18,8 @@ import { Language } from 'vue3-gettext' const eventSchema = z.object({ itemid: z.string(), parentitemid: z.string(), - spaceid: z.string().optional() + spaceid: z.string().optional(), + initiatorid: z.string().optional() }) const itemInCurrentFolder = ({ @@ -50,6 +52,7 @@ export const onSSEItemRenamedEvent = async ({ topic, resourcesStore, spacesStore, + clientStore, msg, clientService, router @@ -57,12 +60,23 @@ export const onSSEItemRenamedEvent = async ({ topic: string resourcesStore: ResourcesStore spacesStore: SpacesStore + clientStore msg: MessageEvent clientService: ClientService router: Router }) => { try { const sseData = eventSchema.parse(JSON.parse(msg.data)) + + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the request was initiated by the current client (browser tab), + * there's no need to proceed with the action since the web already + * handles its own business logic. Therefore, we'll return early here. + */ + return + } + const currentFolder = resourcesStore.currentFolder const resourceIsCurrentFolder = currentFolder.id === sseData.itemid const resource = resourceIsCurrentFolder @@ -138,16 +152,16 @@ export const onSSEProcessingFinishedEvent = async ({ topic, resourcesStore, spacesStore, + clientStore, msg, - // eslint-disable-next-line @typescript-eslint/no-unused-vars clientService, - // eslint-disable-next-line @typescript-eslint/no-unused-vars resourceQueue, previewService }: { topic: string resourcesStore: ResourcesStore spacesStore: SpacesStore + clientStore: ClientStore msg: MessageEvent clientService: ClientService resourceQueue: PQueue @@ -160,41 +174,48 @@ export const onSSEProcessingFinishedEvent = async ({ return false } const resource = resourcesStore.resources.find((f) => f.id === sseData.itemid) - const space = spacesStore.spaces.find((s) => s.id === resource.storageId) - const isFileLoaded = !!resource - if (isFileLoaded) { - resourcesStore.updateResourceField({ - id: sseData.itemid, - field: 'processing', - value: false - }) + /** + * If resource is not loaded, it suggests an upload is in progress. + */ + if (!resource) { + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the upload is initiated by the current client, + * there's no necessity to retrieve the resources again. + */ + return + } - if (space) { - const preview = await previewService.loadPreview({ - resource, - space, - dimensions: ImageDimension.Thumbnail + return resourceQueue.add(async () => { + const { resource } = await clientService.webdav.listFilesById({ + fileId: sseData.itemid }) + resourcesStore.upsertResource(resource) + }) + } + + resourcesStore.updateResourceField({ + id: sseData.itemid, + field: 'processing', + value: false + }) - if (preview) { - resourcesStore.updateResourceField({ - id: sseData.itemid, - field: 'thumbnail', - value: preview - }) - } + const space = spacesStore.spaces.find((s) => s.id === resource.storageId) + if (space) { + const preview = await previewService.loadPreview({ + resource, + space, + dimensions: ImageDimension.Thumbnail + }) + + if (preview) { + resourcesStore.updateResourceField({ + id: sseData.itemid, + field: 'thumbnail', + value: preview + }) } - } else { - // FIXME: we currently cannot do this, we need to block this for ongoing uploads and copy operations - // when fixing revert the changelog removal - // resourceQueue.add(async () => { - // const { resource } = await clientService.webdav.listFilesById({ - // fileId: sseData.itemid - // }) - // resource.path = urlJoin(currentFolder.path, resource.name) - // resourcesStore.upsertResource(resource) - // }) } } catch (e) { console.error(`Unable to parse sse event ${topic} data`, e) @@ -206,25 +227,28 @@ export const onSSEItemTrashedEvent = async ({ language, messageStore, resourcesStore, + clientStore, msg }: { topic: string language: Language resourcesStore: ResourcesStore + clientStore: ClientStore messageStore: MessageStore msg: MessageEvent }) => { try { - /** - * TODO: Implement a mechanism to identify the client that initiated the trash event. - * Currently, a timeout is utilized to ensure the frontend business logic execution, - * preventing the user from being prompted to navigate to 'another location' - * if the active current folder has been deleted. - * If the initiating client matches the current client, no action is required. - */ - await new Promise((resolve) => setTimeout(resolve, 500)) - const sseData = eventSchema.parse(JSON.parse(msg.data)) + + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the request was initiated by the current client (browser tab), + * there's no need to proceed with the action since the web already + * handles its own business logic. Therefore, we'll return early here. + */ + return + } + const currentFolder = resourcesStore.currentFolder const resourceIsCurrentFolder = currentFolder.id === sseData.itemid @@ -252,18 +276,29 @@ export const onSSEItemRestoredEvent = async ({ topic, resourcesStore, spacesStore, + clientStore, msg, clientService }: { topic: string resourcesStore: ResourcesStore spacesStore: SpacesStore + clientStore: ClientStore msg: MessageEvent clientService: ClientService }) => { try { const sseData = eventSchema.parse(JSON.parse(msg.data)) + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the request was initiated by the current client (browser tab), + * there's no need to proceed with the action since the web already + * handles its own business logic. Therefore, we'll return early here. + */ + return + } + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) if (!space) { return @@ -294,3 +329,108 @@ export const onSSEItemRestoredEvent = async ({ console.error(`Unable to parse sse event ${topic} data`, e) } } + +/** + * The FileTouched event is triggered when a new empty file, such as a new text file, + * is about to be created on the server. This event is necessary because the + * post-processing event won't be triggered in this case. + */ +export const onSSEFileTouchedEvent = async ({ + topic, + resourcesStore, + spacesStore, + clientStore, + msg, + clientService +}: { + topic: string + resourcesStore: ResourcesStore + spacesStore: SpacesStore + clientStore: ClientStore + msg: MessageEvent + clientService: ClientService +}) => { + try { + const sseData = eventSchema.parse(JSON.parse(msg.data)) + + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the request was initiated by the current client (browser tab), + * there's no need to proceed with the action since the web already + * handles its own business logic. Therefore, we'll return early here. + */ + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (!itemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { + return false + } + + resourcesStore.upsertResource(resource) + } catch (e) { + console.error(`Unable to parse sse event ${topic} data`, e) + } +} + +export const onSSEFolderCreatedEvent = async ({ + topic, + resourcesStore, + spacesStore, + clientStore, + msg, + clientService +}: { + topic: string + resourcesStore: ResourcesStore + spacesStore: SpacesStore + clientStore: ClientStore + msg: MessageEvent + clientService: ClientService +}) => { + try { + const sseData = eventSchema.parse(JSON.parse(msg.data)) + + if (sseData.initiatorid === clientStore.clientInitiatorId) { + /** + * If the request was initiated by the current client (browser tab), + * there's no need to proceed with the action since the web already + * handles its own business logic. Therefore, we'll return early here. + */ + return + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + if (!itemInCurrentFolder({ resourcesStore, parentFolderId: resource.parentFolderId })) { + return false + } + + resourcesStore.upsertResource(resource) + } catch (e) { + console.error(`Unable to parse sse event ${topic} data`, e) + } +} diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index eac778f4520..996f5e7cdde 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -60,7 +60,8 @@ export const bootstrapApp = async (configurationPath: string): Promise => userStore, resourcesStore, messagesStore, - sharesStore + sharesStore, + clientStore } = announcePiniaStores() app.provide('$router', router) @@ -82,7 +83,8 @@ export const bootstrapApp = async (configurationPath: string): Promise => configStore, userStore, authStore, - capabilityStore + capabilityStore, + clientStore }) // TODO: move to announceArchiverService function app.config.globalProperties.$archiverService = new ArchiverService( @@ -196,6 +198,7 @@ export const bootstrapApp = async (configurationPath: string): Promise => language: gettext, resourcesStore, spacesStore, + clientStore, messageStore: messagesStore, clientService, previewService,