diff --git a/frontend/src/react/OpBlockNoteContainer.tsx b/frontend/src/react/OpBlockNoteContainer.tsx index 7834969c661..2c194c12330 100644 --- a/frontend/src/react/OpBlockNoteContainer.tsx +++ b/frontend/src/react/OpBlockNoteContainer.tsx @@ -36,21 +36,25 @@ import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlock import { HocuspocusProvider } from '@hocuspocus/provider'; import { OpColorMode } from 'core-app/core/setup/globals/theme-utils'; import { IUploadFile } from 'core-app/core/upload/upload.service'; +import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; import { initOpenProjectApi, openProjectWorkPackageBlockSpec, openProjectWorkPackageSlashMenu } from 'op-blocknote-extensions'; import { useEffect, useState } from 'react'; +import { firstValueFrom } from 'rxjs'; import * as Y from 'yjs'; +interface CollaborativeUser { + name:string; + color:string; +} + export interface OpBlockNoteContainerProps { inputField:HTMLInputElement; inputText?:string; - hocuspocusUrl:string; - oauthToken:string, activeUser:User; - documentName:string; - documentId:string; openProjectUrl:string; attachmentsUploadUrl:string; attachmentsCollectionKey:string; + hocuspocusProvider?:HocuspocusProvider; } const schema = BlockNoteSchema.create().extend({ @@ -63,14 +67,11 @@ const detectTheme = ():OpColorMode => { return window.OpenProject.theme.detectOp export default function OpBlockNoteContainer({ inputField, inputText, - hocuspocusUrl, - oauthToken, activeUser, - documentName, - documentId, openProjectUrl, attachmentsUploadUrl, - attachmentsCollectionKey }:OpBlockNoteContainerProps) { + attachmentsCollectionKey, + hocuspocusProvider }:OpBlockNoteContainerProps) { const [isLoading, setIsLoading] = useState(true); const [theme, setTheme] = useState(detectTheme()); @@ -80,33 +81,20 @@ export default function OpBlockNoteContainer({ inputField, const blockNoteLocaleString = Object.keys(blockNoteLocales).includes(userLocale) ? userLocale : 'en'; const blockNoteLocale = blockNoteLocales[blockNoteLocaleString as keyof typeof blockNoteLocales]; - let doc = new Y.Doc(); - - const collaborationEnabled = Boolean(hocuspocusUrl && documentName && oauthToken && activeUser); - let hocuspocusProvider:HocuspocusProvider | null = null; + let doc = LiveCollaborationManager.ydoc; let editorParams:Partial>; - if(collaborationEnabled) { - const url = new URL(hocuspocusUrl); - url.searchParams.set('document_id', documentId); - url.searchParams.set('openproject_base_path', openProjectUrl); - - hocuspocusProvider = new HocuspocusProvider({ - url: url.toString(), - name: documentName, - token: oauthToken, - document: doc - }); - + if(hocuspocusProvider) { editorParams = { schema, collaboration: { provider: hocuspocusProvider, fragment: doc.getXmlFragment('document-store'), user: { + id: activeUser.id, name: activeUser.username, color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'), - }, + } as unknown as CollaborativeUser, showCursorLabels: 'activity' }, dictionary: blockNoteLocale, @@ -158,7 +146,9 @@ export default function OpBlockNoteContainer({ inputField, try { const service = pluginContext.services.attachmentsResourceService; const iUploadFile = fileToIUploadFile(file); - const result = await service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile]).toPromise(); + const result = await firstValueFrom( + service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile]) + ); return result?.[0]._links.staticDownloadLocation.href ?? ''; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -185,7 +175,8 @@ export default function OpBlockNoteContainer({ inputField, inputField.value = b64; }; - if(collaborationEnabled && hocuspocusProvider) { + if(hocuspocusProvider) { + if (hocuspocusProvider.synced) { setIsLoading(false); } hocuspocusProvider.on('synced', () => setIsLoading(false)); hocuspocusProvider.on('disconnect', () => setIsLoading(true)); } else { @@ -194,14 +185,14 @@ export default function OpBlockNoteContainer({ inputField, } return () => { - if (collaborationEnabled && hocuspocusProvider) { + if (hocuspocusProvider) { hocuspocusProvider.destroy(); } else { // disable Yjs update listener. Opposite of doc.on('update', ...); doc.off('update', updateInput); } }; - }, []); + }, [hocuspocusProvider]); useEffect(() => { const handleThemeChange = () => { diff --git a/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts b/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts index 81dd60f35a3..569f74edbaf 100644 --- a/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/block-note.controller.ts @@ -28,11 +28,13 @@ * ++ */ +import { User } from '@blocknote/core/comments'; +import type { HocuspocusProvider } from '@hocuspocus/provider'; import { Controller } from '@hotwired/stimulus'; +import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; import React from 'react'; import { createRoot } from 'react-dom/client'; import OpBlockNoteContainer from '../../../react/OpBlockNoteContainer'; -import { User } from '@blocknote/core/comments'; export default class extends Controller { static targets = [ @@ -40,47 +42,48 @@ export default class extends Controller { 'blockNoteInputField', ]; + declare readonly blockNoteEditorTarget:HTMLElement; + declare readonly blockNoteInputFieldTarget:HTMLInputElement; + static values = { inputText: String, activeUser: Object, - hocuspocusUrl: String, - oauthToken: String, - documentName: String, - documentId: String, openProjectUrl: String, attachmentsUploadUrl: String, attachmentsCollectionKey: String, + + collaborationEnabled: Boolean, }; - declare readonly blockNoteEditorTarget:HTMLElement; - declare readonly blockNoteInputFieldTarget:HTMLInputElement; declare readonly inputTextValue:string; declare readonly activeUserValue:User; - declare readonly hocuspocusUrlValue:string; - declare readonly oauthTokenValue:string; - declare readonly documentNameValue:string; - declare readonly documentIdValue:string; declare readonly openProjectUrlValue:string; declare readonly attachmentsUploadUrlValue:string; declare readonly attachmentsCollectionKeyValue:string; + declare readonly collaborationEnabledValue:string; + connect() { const root = createRoot(this.blockNoteEditorTarget); - root.render(this.BlockNoteReactContainer()); + + if (this.collaborationEnabledValue) { + LiveCollaborationManager.onReady((hocuspocusProvider) => { + root.render(this.BlockNoteReactContainer(hocuspocusProvider)); + }); + } else { + root.render(this.BlockNoteReactContainer()); + } } - BlockNoteReactContainer() { + BlockNoteReactContainer(hocuspocusProvider?:HocuspocusProvider) { return React.createElement(OpBlockNoteContainer, { inputField: this.blockNoteInputFieldTarget, inputText: this.inputTextValue, - hocuspocusUrl: this.hocuspocusUrlValue, - oauthToken: this.oauthTokenValue, activeUser: this.activeUserValue, - documentName: this.documentNameValue, - documentId: this.documentIdValue, openProjectUrl: this.openProjectUrlValue, attachmentsUploadUrl: this.attachmentsUploadUrlValue, attachmentsCollectionKey: this.attachmentsCollectionKeyValue, + hocuspocusProvider: hocuspocusProvider, }); } } diff --git a/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts b/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts new file mode 100644 index 00000000000..1940ca0c2e2 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/documents/init-yjs-provider.controller.ts @@ -0,0 +1,64 @@ +/* + * -- copyright + * openproject is an open source project management software. + * copyright (c) the openproject gmbh + * + * this program is free software; you can redistribute it and/or + * modify it under the terms of the gnu general public license version 3. + * + * openproject is a fork of chiliproject, which is a fork of redmine. the copyright follows: + * copyright (c) 2006-2013 jean-philippe lang + * copyright (c) 2010-2013 the chiliproject team + * + * this program is free software; you can redistribute it and/or + * modify it under the terms of the gnu general public license + * as published by the free software foundation; either version 2 + * of the license, or (at your option) any later version. + * + * this program is distributed in the hope that it will be useful, + * but without any warranty; without even the implied warranty of + * merchantability or fitness for a particular purpose. see the + * gnu general public license for more details. + * + * you should have received a copy of the gnu general public license + * along with this program; if not, write to the free software + * foundation, inc., 51 franklin street, fifth floor, boston, ma 02110-1301, usa. + * + * see copyright and license files for more details. + * ++ + */ + +import { HocuspocusProvider } from '@hocuspocus/provider'; +import { Controller } from '@hotwired/stimulus'; +import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; + +export default class extends Controller { + static values = { + hocuspocusUrl: String, + openProjectUrl: String, + oauthToken: String, + documentName: String, + documentId: String, + }; + + declare readonly hocuspocusUrlValue:string; + declare readonly openProjectUrlValue:string; + declare readonly oauthTokenValue:string; + declare readonly documentNameValue:string; + declare readonly documentIdValue:string; + + connect():void { + const url = new URL(this.hocuspocusUrlValue); + url.searchParams.set('document_id', this.documentIdValue); + url.searchParams.set('openproject_base_path', this.openProjectUrlValue); + + LiveCollaborationManager.initializeYjsProvider( + new HocuspocusProvider({ + url: url.toString(), + name: this.documentNameValue, + token: this.oauthTokenValue, + document: LiveCollaborationManager.ydoc, + }) + ); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/documents/live-users.controller.ts b/frontend/src/stimulus/controllers/dynamic/documents/live-users.controller.ts new file mode 100644 index 00000000000..6c749fe5bca --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/documents/live-users.controller.ts @@ -0,0 +1,115 @@ +/* + * -- copyright + * openproject is an open source project management software. + * copyright (c) the openproject gmbh + * + * this program is free software; you can redistribute it and/or + * modify it under the terms of the gnu general public license version 3. + * + * openproject is a fork of chiliproject, which is a fork of redmine. the copyright follows: + * copyright (c) 2006-2013 jean-philippe lang + * copyright (c) 2010-2013 the chiliproject team + * + * this program is free software; you can redistribute it and/or + * modify it under the terms of the gnu general public license + * as published by the free software foundation; either version 2 + * of the license, or (at your option) any later version. + * + * this program is distributed in the hope that it will be useful, + * but without any warranty; without even the implied warranty of + * merchantability or fitness for a particular purpose. see the + * gnu general public license for more details. + * + * you should have received a copy of the gnu general public license + * along with this program; if not, write to the free software + * foundation, inc., 51 franklin street, fifth floor, boston, ma 02110-1301, usa. + * + * see copyright and license files for more details. + * ++ + */ + +import { HocuspocusProvider, onAwarenessUpdateParameters } from '@hocuspocus/provider'; +import * as Turbo from '@hotwired/turbo'; +import { LiveCollaborationManager } from 'core-stimulus/helpers/live-collaboration-helpers'; +import { ApplicationController, useDebounce } from 'stimulus-use'; + +interface LiveUser {id:string, name:string, avatarUrl:string} + +export default class extends ApplicationController { + static debounces = ['triggerUpdateUI']; + static targets = ['users', 'popover']; + + declare readonly usersTarget:HTMLElement; + declare readonly popoverTarget:HTMLElement; + + private provider:HocuspocusProvider|undefined; + private currentUsers = new Map(); + + connect() { + LiveCollaborationManager.onReady((provider:HocuspocusProvider) => { + this.provider = provider; + this.provider.on('awarenessUpdate', this.onAwarenessUpdate); + }); + + useDebounce(this, { wait: 1000 }); + } + + disconnect() { + this.currentUsers.clear(); + this.provider?.off('awarenessUpdate', this.onAwarenessUpdate); + } + + toggle_popover() { + this.popoverTarget.classList.toggle('d-none'); + } + + private onAwarenessUpdate = (data:onAwarenessUpdateParameters) => { + const changed = this.updateUsers(data.states); + if (changed) { + this.triggerUpdateUI(); + } + }; + + private updateUsers(states:onAwarenessUpdateParameters['states']) { + const nextState = new Map(); + + states.forEach((state, clientId) => { + if (state.user) { + nextState.set(clientId, state.user as LiveUser); + } + }); + + const previousUsers = [...this.currentUsers.keys()]; + const nextUsers = [...nextState.keys()]; + + this.currentUsers = nextState; + + return ( + previousUsers.length !== nextUsers.length || previousUsers.some(id => !nextUsers.includes(id)) + ); + } + + private triggerUpdateUI() { + const params = new URLSearchParams(); + + for (const user of this.currentUsers.values()) { + params.append('user_ids[]', user.id); + } + + const url = `${window.location.pathname}/render_avatars?${params}`; + + void fetch(url, { + method: 'GET', + headers: { Accept: 'text/vnd.turbo-stream.htm' }, + }) + .then((response:Response) => { + if (response.ok) { + return response.text(); + } + return Promise.reject(new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)); + }) + .then((html:string) => Turbo.renderStreamMessage(html)) + .catch((error:Error) => console.error('Error:', error)); + } +} + diff --git a/frontend/src/stimulus/helpers/live-collaboration-helpers.ts b/frontend/src/stimulus/helpers/live-collaboration-helpers.ts new file mode 100644 index 00000000000..fab7874f75b --- /dev/null +++ b/frontend/src/stimulus/helpers/live-collaboration-helpers.ts @@ -0,0 +1,81 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { HocuspocusProvider } from '@hocuspocus/provider'; +import type { Doc } from 'yjs'; +import * as Y from 'yjs'; + +type Listener = (provider:HocuspocusProvider) => void; + +class LiveCollaborationManagerClass { + ydocInstance:Doc|null = null; + yjsProviderInstance:HocuspocusProvider|null = null; + + private listeners:Listener[] = []; + + /** + * Initializes the YJS Provider + * @param provider The provider to use + * @returns void + */ + initializeYjsProvider(provider:HocuspocusProvider) { + this.yjsProviderInstance = provider; + this.listeners.forEach((listener) => listener(this.yjsProviderInstance!)); + } + + /** + * Gets a shared Y.Doc instance + */ + get ydoc():Doc { + this.ydocInstance ??= new Y.Doc(); + + return this.ydocInstance; + } + + /** + * Gets a shared YJS Provider + * @throws Error if no provider is configured + */ + get yjsProvider():HocuspocusProvider { + if (!this.yjsProviderInstance) { + throw new Error('No YJS Provider configured'); + } + return this.yjsProviderInstance; + } + + onReady(listener:Listener) { + this.listeners.push(listener); + if (this.yjsProviderInstance) { + listener(this.yjsProviderInstance); + } + } +} + +export const LiveCollaborationManager = new LiveCollaborationManagerClass(); diff --git a/lib/primer/open_project/forms/block_note_editor.html.erb b/lib/primer/open_project/forms/block_note_editor.html.erb index db03a0fadfa..5b5a50f3d2f 100644 --- a/lib/primer/open_project/forms/block_note_editor.html.erb +++ b/lib/primer/open_project/forms/block_note_editor.html.erb @@ -37,13 +37,9 @@ controller: "block-note", block_note_input_text_value: value, block_note_active_user_value: active_user, - block_note_hocuspocus_url_value: hocuspocus_url, - block_note_document_name_value: document_name, - block_note_document_id_value: document_id, - block_note_open_project_url_value: open_project_url, - block_note_oauth_token_value: oauth_token, block_note_attachments_upload_url_value: attachments_upload_url, block_note_attachments_collection_key_value: attachments_collection_key, + block_note_collaboration_enabled_value: collaboration_enabled, test_selector: "blocknote-document-description" } ) do diff --git a/lib/primer/open_project/forms/block_note_editor.rb b/lib/primer/open_project/forms/block_note_editor.rb index a5890bd9960..45b598c5349 100644 --- a/lib/primer/open_project/forms/block_note_editor.rb +++ b/lib/primer/open_project/forms/block_note_editor.rb @@ -38,18 +38,13 @@ module Primer attr_reader :input, :value, :active_user, - :hocuspocus_url, - :open_project_url, - :document_name, - :document_id, - :oauth_token, :attachments_upload_url, - :attachments_collection_key + :attachments_collection_key, + :collaboration_enabled delegate :name, to: :@input - def initialize(input:, value:, document_name:, document_id:, attachments_upload_url: "", - attachments_collection_key: "", oauth_token: nil) + def initialize(input:, value:, attachments_upload_url: "", attachments_collection_key: "") super() @input = input @value = value @@ -57,13 +52,10 @@ module Primer id: User.current.id, username: User.current.name } - @document_id = document_id - @document_name = document_name - @oauth_token = oauth_token - @hocuspocus_url = Setting.collaborative_editing_hocuspocus_url - @open_project_url = root_url @attachments_upload_url = attachments_upload_url @attachments_collection_key = attachments_collection_key + + @collaboration_enabled = true end end end diff --git a/lib/primer/open_project/forms/dsl/block_note_editor_input.rb b/lib/primer/open_project/forms/dsl/block_note_editor_input.rb index f6286d92548..8e8f7091b0d 100644 --- a/lib/primer/open_project/forms/dsl/block_note_editor_input.rb +++ b/lib/primer/open_project/forms/dsl/block_note_editor_input.rb @@ -37,9 +37,6 @@ module Primer :label, :value, :classes, - :document_id, - :document_name, - :oauth_token, :attachments_upload_url, :attachments_collection_key @@ -47,18 +44,13 @@ module Primer # @param name [String] The name of the input field. # @param label [String] The label for the input field. # @param value [String] The initial value of the input in base64 format. - # @param document_id [String] The ID of the document. - # @param document_name [String] The name of the document for the collaborative YJS provider. - # @param oauth_token [String, nil] The OAuth token for external server authentication. - def initialize(name:, label:, value:, document_id:, document_name:, attachments_upload_url: "", - attachments_collection_key: "", oauth_token: nil, **system_arguments) + # @param attachments_upload_url [String] The URL to which attachments will be uploaded. + # @param attachments_collection_key [String] The collection key for attachments. + def initialize(name:, label:, value:, attachments_upload_url: "", attachments_collection_key: "", **system_arguments) @name = name @label = label @value = value @classes = system_arguments[:classes] - @document_id = document_id - @document_name = document_name - @oauth_token = oauth_token @attachments_upload_url = attachments_upload_url @attachments_collection_key = attachments_collection_key @@ -66,8 +58,7 @@ module Primer end def to_component - BlockNoteEditor.new(input: self, value:, document_id:, document_name:, oauth_token:, attachments_upload_url:, - attachments_collection_key:) + BlockNoteEditor.new(input: self, value:, attachments_upload_url:, attachments_collection_key:) end def type diff --git a/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb b/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb index dbac8bac4f6..28764f9bd48 100644 --- a/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/block_note_editor_component.html.erb @@ -29,6 +29,17 @@ ++# %> +<% + helpers.content_controller( + "documents--init-yjs-provider", + "documents--init-yjs-provider-hocuspocus-url-value": Setting.collaborative_editing_hocuspocus_url, + "documents--init-yjs-provider-open-project-url-value": root_url, + "documents--init-yjs-provider-oauth-token-value": oauth_token, + "documents--init-yjs-provider-document-name-value": document.title, + "documents--init-yjs-provider-document-id-value": document.id + ) +%> + <%= render(Primer::Alpha::Layout.new(stacking_breakpoint: :md, classes: "op-document-view")) do |component| component.with_main(classes: "op-document-view--main") do diff --git a/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb b/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb index 4535f41d05e..8c98f23993b 100644 --- a/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb +++ b/modules/documents/app/components/documents/show_edit_view/page_header/info_line_component.html.erb @@ -52,6 +52,10 @@ end end + flex.with_column(mr: 2) do + render(Documents::ShowEditView::PageHeader::LiveUsersComponent.new) + end + flex.with_column do last_updated_at_content end diff --git a/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.html.erb b/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.html.erb new file mode 100644 index 00000000000..40a0b170e7d --- /dev/null +++ b/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.html.erb @@ -0,0 +1,84 @@ +<%# + -- copyright + OpenProject is an open source project management software. + Copyright (C) the OpenProject GmbH + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License version 3. + + OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + Copyright (C) 2006-2013 Jean-Philippe Lang + Copyright (C) 2010-2013 the ChiliProject Team + + This program is free software; you can redistribute it and/or + modify it under the terms of the GNU General Public License + as published by the Free Software Foundation; either version 2 + of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + See COPYRIGHT and LICENSE files for more details. + + ++# +%> + +<%= + component_wrapper( + tag: "turbo-frame", refresh: :morph, + data: { + controller: "documents--live-users", + action: "click->documents--live-users#toggle_popover", + "test-selector": "live-users" + } + ) do + render Primer::BaseComponent.new(tag: :div, position: :relative) do + concat( + flex_layout do |flex_container| + flex_container.with_column do + render Primer::Beta::AvatarStack.new( + data: { "documents--live-users-target": "users" } + ) do |component| + users.map do |user| + component.with_avatar(src: avatar_url(user), alt: user.name) + end + end + end + + flex_container.with_column { active_editors } + end + ) + + concat( + render Primer::Beta::Popover.new( + position: :absolute, + display: :none, # hidden by default + data: { "documents--live-users-target": "popover" } + ) do |component| + component.with_heading { I18n.t("documents.active_editors") } + component.with_body(caret: :top_left) do + flex_layout do |container| + users.each do |user| + container.with_row do + render Users::AvatarComponent.new( + user: user, + show_name: true, + link: false, + hover_card: { active: false }, + size: :mini + ) + end + end + end + end + end + ) + end + end +%> diff --git a/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.rb b/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.rb new file mode 100644 index 00000000000..6eff7317912 --- /dev/null +++ b/modules/documents/app/components/documents/show_edit_view/page_header/live_users_component.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +# + +module Documents + module ShowEditView + module PageHeader + class LiveUsersComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpPrimer::FormHelpers + include OpTurbo::Streamable + include AvatarHelper + + attr_reader :users + + def initialize(users: [User.current]) + super + @users = users + end + + def active_editors + safe_join [ + I18n.t("documents.active_editors_count", count: users.count).html_safe + ] + end + end + end + end +end diff --git a/modules/documents/app/controllers/documents_controller.rb b/modules/documents/app/controllers/documents_controller.rb index 9220606c72a..73718a4c1b0 100644 --- a/modules/documents/app/controllers/documents_controller.rb +++ b/modules/documents/app/controllers/documents_controller.rb @@ -66,6 +66,14 @@ class DocumentsController < ApplicationController end end + def render_avatars + user_ids = params[:user_ids] + @users = User.where(id: user_ids) + update_via_turbo_stream(component: Documents::ShowEditView::PageHeader::LiveUsersComponent.new(users: @users)) + + respond_with_turbo_streams + end + def new @document = @project.documents.build @document.attributes = document_params @@ -194,7 +202,7 @@ class DocumentsController < ApplicationController def generate_oauth_token # do not generate a token if the user is not allowed to manage documents - if !current_user.allowed_in_project?(:manage_documents, @project) + if !current_user.allowed_in_project?(:view_documents, @project) return end diff --git a/modules/documents/app/forms/documents/block_note_editor_form.rb b/modules/documents/app/forms/documents/block_note_editor_form.rb index 091252df3aa..4529ea10462 100644 --- a/modules/documents/app/forms/documents/block_note_editor_form.rb +++ b/modules/documents/app/forms/documents/block_note_editor_form.rb @@ -37,9 +37,6 @@ module Documents visually_hide_label: true, classes: "document-form--long-description", value: model.content_binary, - document_id: model.id, - document_name: model.title, - oauth_token: @oauth_token, attachments_upload_url:, attachments_collection_key: ) diff --git a/modules/documents/app/views/documents/show.html.erb b/modules/documents/app/views/documents/show.html.erb index b1fe40de16d..1972c4eb006 100644 --- a/modules/documents/app/views/documents/show.html.erb +++ b/modules/documents/app/views/documents/show.html.erb @@ -40,3 +40,4 @@ <% else %> <%= render partial: "classic_show", locals: { document: @document, project: @project, attachments: @attachments } %> <% end %> + diff --git a/modules/documents/config/locales/en.yml b/modules/documents/config/locales/en.yml index 860ed153669..e277e8273ef 100644 --- a/modules/documents/config/locales/en.yml +++ b/modules/documents/config/locales/en.yml @@ -76,6 +76,9 @@ en: last_updated_at: "Last updated %{time}." + active_editors: "Active editors" + active_editors_count: "%{count} active editors" + label_attachment_author: "Attachment author" label_categories: "Categories" new_category: "New category" diff --git a/modules/documents/config/routes.rb b/modules/documents/config/routes.rb index ae0ecfd996e..8f0677aa4bd 100644 --- a/modules/documents/config/routes.rb +++ b/modules/documents/config/routes.rb @@ -45,6 +45,7 @@ Rails.application.routes.draw do get :cancel_title_edit, defaults: { format: :turbo_stream } put :update_type, defaults: { format: :turbo_stream } get :delete_dialog + get :render_avatars, defaults: { format: :turbo_stream } end end diff --git a/modules/documents/lib/open_project/documents/engine.rb b/modules/documents/lib/open_project/documents/engine.rb index 5176cfcf5a1..8c6ff9bdaae 100644 --- a/modules/documents/lib/open_project/documents/engine.rb +++ b/modules/documents/lib/open_project/documents/engine.rb @@ -53,7 +53,7 @@ module OpenProject::Documents project_module :documents do |_map| permission :view_documents, - { documents: %i[index search show download], + { documents: %i[index search show download render_avatars], "documents/menus": %i[show] }, permissible_on: :project permission :manage_documents, diff --git a/modules/documents/spec/controllers/documents_controller_spec.rb b/modules/documents/spec/controllers/documents_controller_spec.rb index 3525cd135e0..3c07fe56efa 100644 --- a/modules/documents/spec/controllers/documents_controller_spec.rb +++ b/modules/documents/spec/controllers/documents_controller_spec.rb @@ -211,9 +211,9 @@ RSpec.describe DocumentsController do context "when user does not have manage_documents permission" do current_user { user_without_manage } - it "does not generate an OAuth token for show action" do + it "generates an OAuth token for show action" do get :show, params: { id: document.id } - expect(assigns(:oauth_token)).to be_nil + expect(assigns(:oauth_token)).to be_present end end end diff --git a/modules/documents/spec/features/attachment_upload_spec.rb b/modules/documents/spec/features/attachment_upload_spec.rb index d994e0efb96..83e48909fcf 100644 --- a/modules/documents/spec/features/attachment_upload_spec.rb +++ b/modules/documents/spec/features/attachment_upload_spec.rb @@ -55,6 +55,11 @@ RSpec.describe "Upload attachment to documents", before do login_as(user) + + # This is here while we don't have a setting defined for enabling/disabling collaboration + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) + # rubocop:enable RSpec/AnyInstance end shared_examples "can upload an image in CKEditor" do diff --git a/modules/documents/spec/features/block_note_editor_spec.rb b/modules/documents/spec/features/block_note_editor_spec.rb index 242fd0f85ee..925e40d33fa 100644 --- a/modules/documents/spec/features/block_note_editor_spec.rb +++ b/modules/documents/spec/features/block_note_editor_spec.rb @@ -38,6 +38,11 @@ RSpec.describe "BlockNote editor rendering", :js, with_flag: { block_note_editor before do login_as(admin) + + # This is here while we don't have a setting defined for enabling/disabling collaboration + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) + # rubocop:enable RSpec/AnyInstance end it "renders the BlockNote editor in the users locale" do diff --git a/modules/documents/spec/features/documents/project/show_edit_document_spec.rb b/modules/documents/spec/features/documents/project/show_edit_document_spec.rb index c34c208f7cd..471b879e701 100644 --- a/modules/documents/spec/features/documents/project/show_edit_document_spec.rb +++ b/modules/documents/spec/features/documents/project/show_edit_document_spec.rb @@ -46,11 +46,24 @@ RSpec.describe "Show/Edit Document View", current_user { member } + before do + # This is here while we don't have a setting defined for enabling/disabling collaboration + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Primer::OpenProject::Forms::BlockNoteEditor).to receive(:collaboration_enabled).and_return(false) + # rubocop:enable RSpec/AnyInstance + end + it "renders a collaborative document" do visit document_path(document) expect(page).to have_content("Collaborative document") + aggregate_failures "can see live users" do + within_test_selector("live-users") do + expect(page).to have_content("1 active editors") + end + end + aggregate_failures "can edit document title" do within_test_selector("document-page-header") do click_button accessible_name: "Document actions"