diff --git a/cypress/e2e/share.spec.js b/cypress/e2e/share.spec.js index b494e3d2ffc..7f869705047 100644 --- a/cypress/e2e/share.spec.js +++ b/cypress/e2e/share.spec.js @@ -116,22 +116,16 @@ describe('Open test.md in viewer', function () { }) .then(() => { cy.getEditor().should('be.visible') - cy.getContent() - .should('contain', 'Hello world') - .find('h2') - .should('contain', 'Hello world') - - cy.intercept({ - method: 'POST', - url: '**/apps/text/public/session/*/session', - }).as('updateSession') + cy.getContent().find('h2').should('contain', 'Hello world') cy.get('button.avatar-list').click() + cy.get('.session-menu button').click() cy.get('.guest-name-dialog input[type="text"]').type( 'someone{enter}', ) - cy.wait('@updateSession') - .its('response.body.guestName') - .should('eq', 'someone') + cy.get('.session-menu .session-label.guest').should( + 'contain', + 'someone', + ) }) }) diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 4a1fc70c263..55eed4332a5 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -71,10 +71,9 @@ public function __construct( public function initSession(int $documentId, ?string $guestName = null): Session { $session = new Session(); $session->setDocumentId($documentId); - $userName = $this->userId ?? $guestName; $session->setUserId($this->userId); $session->setToken($this->secureRandom->generate(64)); - $session->setColor($this->getColorForGuestName($guestName ?? '')); + $session->setColor($this->getColor()); if ($this->userId === null) { $session->setGuestName($guestName ?? ''); } @@ -223,9 +222,19 @@ public function updateSessionAwareness(Session $session, string $message): Sessi return $this->sessionMapper->update($session); } + private function getColor(string $guestName = ''): string { + if ($this->userId === null) { + return $this->getColorForGuestName($guestName); + } + $name = $this->userManager->getDisplayName($this->userId) ?? $this->userId; + $color = $this->avatarManager->getAvatar($this->userId)->avatarBackgroundColor($name); + return $color->name(); + } + private function getColorForGuestName(string $guestName = ''): string { - $guestName = $this->userId !== null ? $guestName : ''; - $uniqueGuestId = !empty($guestName) ? $guestName : $this->secureRandom->generate(12); + $uniqueGuestId = !empty($guestName) + ? $guestName . '(guest)' // make it harder to impersonate users. + : $this->secureRandom->generate(12); $color = $this->avatarManager->getGuestAvatar($uniqueGuestId)->avatarBackgroundColor($uniqueGuestId); return $color->name(); } diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 57cdbe02df1..a9c5c800d83 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -6,7 +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' +import type { Document, GuestSession, Session } from '../services/SyncService.js' export interface OpenParams { fileId?: number @@ -58,7 +58,7 @@ export async function open( export async function update( guestName: string, connection: Connection, -): Promise { +): Promise { if (!connection.shareToken) { throw new Error('Cannot set guest name without a share token!') } diff --git a/src/components/Editor.vue b/src/components/Editor.vue index d7f9ddab1b4..7ba706612a1 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -31,7 +31,6 @@ @@ -48,7 +47,6 @@ @@ -85,7 +83,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { File } from '@nextcloud/files' import { Collaboration } from '@tiptap/extension-collaboration' import { useElementSize } from '@vueuse/core' -import Vue, { defineComponent, ref, set, shallowRef, watch } from 'vue' +import { defineComponent, ref, shallowRef, watch } from 'vue' import { Doc } from 'yjs' import Autofocus from '../extensions/Autofocus.js' @@ -94,7 +92,6 @@ import { provideEditorFlags } from '../composables/useEditorFlags.ts' import { ATTACHMENT_RESOLVER, FILE, IS_MOBILE } from './Editor.provider.ts' import ReadonlyBar from './Menu/ReadonlyBar.vue' -import { t } from '@nextcloud/l10n' import { generateRemoteUrl } from '@nextcloud/router' import { Awareness } from 'y-protocols/awareness.js' import { provideConnection } from '../composables/useConnection.ts' @@ -177,7 +174,6 @@ export default defineComponent({ props: { richWorkspace: { type: Boolean, - require: false, default: false, }, initialSession: { @@ -258,7 +254,7 @@ export default defineComponent({ const { applyEditorWidth } = provideEditorWidth() applyEditorWidth() - const { setEditable } = useEditorMethods(editor) + const { setEditable, updateUser } = useEditorMethods(editor) const serialize = isRichEditor ? () => @@ -278,6 +274,7 @@ export default defineComponent({ return { awareness, + connection, editor, el, hasConnectionIssue, @@ -292,8 +289,8 @@ export default defineComponent({ setEditable, syncProvider, syncService, + updateUser, width, - connection, ydoc, } }, @@ -303,12 +300,8 @@ export default defineComponent({ IDLE_TIMEOUT, document: null, - sessions: [], - currentSession: null, fileNode: null, - filteredSessions: {}, - idle: false, dirty: false, contentLoaded: false, @@ -342,7 +335,7 @@ export default defineComponent({ : '/' }, displayed() { - return (this.currentSession && this.active) || this.syncError + return (this.connection && this.active) || this.syncError }, showLoadingSkeleton() { return (!this.contentLoaded || !this.displayed) && !this.syncError @@ -493,72 +486,24 @@ export default defineComponent({ this.idle = false }, - updateSessions(sessions) { - this.sessions = sessions.sort((a, b) => b.lastContact - a.lastContact) - - // Make sure we get our own session updated - // This should ideally be part of a global store where we can have that updated on the actual name change for guests - const currentUpdatedSession = this.sessions.find( - (session) => session.id === this.currentSession.id, - ) - set(this, 'currentSession', currentUpdatedSession) - - const currentSessionIds = this.sessions.map((session) => session.userId) - const currentGuestIds = this.sessions.map((session) => session.guestId) - - const removedSessions = Object.keys(this.filteredSessions).filter( - (sessionId) => - !currentSessionIds.includes(sessionId) - && !currentGuestIds.includes(sessionId), - ) - - for (const index in removedSessions) { - Vue.delete(this.filteredSessions, removedSessions[index]) - } - for (const index in this.sessions) { - const session = this.sessions[index] - const sessionKey = session.displayName ? session.userId : session.id - if (this.filteredSessions[sessionKey]) { - // update timestamp if relevant - if ( - this.filteredSessions[sessionKey].lastContact - < session.lastContact - ) { - set( - this.filteredSessions[sessionKey], - 'lastContact', - session.lastContact, - ) - } - } else { - set(this.filteredSessions, sessionKey, session) - } - if (session.id === this.currentSession.id) { - set(this.filteredSessions[sessionKey], 'isCurrent', true) - } - } - }, - onOpened({ document, session, content, documentState, readOnly }) { - this.currentSession = session this.document = document this.readOnly = readOnly this.editMode = !readOnly && !this.openReadOnlyEnabled this.hasConnectionIssue = false this.setEditable(this.editMode) - localStorage.setItem('nick', this.currentSession.guestName) this.$attachmentResolver = new AttachmentResolver({ - session: this.currentSession, + session, user: getCurrentUser(), shareToken: this.shareToken, currentDirectory: this.currentDirectory, }) - if (this.currentSession?.userId && this.relativePath?.length) { + if (session.userId && this.relativePath?.length) { const node = new File({ id: this.fileId, source: generateRemoteUrl( - `dav/files/${this.currentSession.userId}${this.relativePath}`, + `dav/files/${session.userId}${this.relativePath}`, ), mime: this.mime, }) @@ -577,18 +522,10 @@ export default defineComponent({ }) } }) - const user = { - name: session?.userId - ? session.displayName - : session?.guestName || t('text', 'Guest'), - color: session?.color, - clientId: this.ydoc.clientID, - } - this.editor.commands.updateUser(user) + this.updateUser(session) }, - onChange({ document, sessions }) { - this.updateSessions.bind(this)(sessions) + onChange({ document }) { this.document = document this.syncError = null diff --git a/src/components/Editor/AvatarWrapper.vue b/src/components/Editor/AvatarWrapper.vue index 93c405b532e..0ca8f4efcdd 100644 --- a/src/components/Editor/AvatarWrapper.vue +++ b/src/components/Editor/AvatarWrapper.vue @@ -7,51 +7,48 @@
+ :disable-tooltip="true" />
- {{ guestInitial }} + +
diff --git a/src/components/Editor/DocumentStatus.vue b/src/components/Editor/DocumentStatus.vue index f44cf29627a..849d5882563 100644 --- a/src/components/Editor/DocumentStatus.vue +++ b/src/components/Editor/DocumentStatus.vue @@ -41,7 +41,7 @@ export default { props: { idle: { type: Boolean, - require: true, + required: true, }, lock: { type: Object, @@ -53,7 +53,7 @@ export default { }, hasConnectionIssue: { type: Boolean, - require: true, + required: true, }, }, diff --git a/src/components/Editor/GuestNameDialog.vue b/src/components/Editor/GuestNameDialog.vue index b84c9694690..da4e50fd2b5 100644 --- a/src/components/Editor/GuestNameDialog.vue +++ b/src/components/Editor/GuestNameDialog.vue @@ -4,110 +4,149 @@ --> - diff --git a/src/components/Editor/PreviewOptions.vue b/src/components/Editor/PreviewOptions.vue index d4b3ee6de3a..14f6bd28de9 100644 --- a/src/components/Editor/PreviewOptions.vue +++ b/src/components/Editor/PreviewOptions.vue @@ -78,7 +78,6 @@ export default { }, href: { type: String, - required: false, default: '', }, }, diff --git a/src/components/Editor/SessionList.vue b/src/components/Editor/SessionList.vue index f19f7c7f445..143a35e3d93 100644 --- a/src/components/Editor/SessionList.vue +++ b/src/components/Editor/SessionList.vue @@ -19,10 +19,9 @@ @@ -33,24 +32,17 @@
    + :session.sync="currentGuestSession" />
  • - - - {{ - session.userId - ? session.displayName - : session.guestName - ? session.guestName - : t('text', 'Guest') - }} + + + {{ displayNameOrGuestName(session) }} - ({{ t('text', 'guest') }})
@@ -64,10 +56,9 @@ import NcButton from '@nextcloud/vue/components/NcButton' import NcPopover from '@nextcloud/vue/components/NcPopover' import AccountMultipleOutlineIcon from 'vue-material-design-icons/AccountMultipleOutline.vue' import { useEditorFlags } from '../../composables/useEditorFlags.ts' -import { - COLLABORATOR_DISCONNECT_TIME, - COLLABORATOR_IDLE_TIME, -} from '../../services/SyncService.ts' +import { useSessions } from '../../composables/useSessions.ts' +import { useSyncService } from '../../composables/useSyncService.ts' +import { COLLABORATOR_IDLE_TIME } from '../../services/SyncService.ts' import AvatarWrapper from './AvatarWrapper.vue' import GuestNameDialog from './GuestNameDialog.vue' @@ -80,53 +71,27 @@ export default { NcButton, NcPopover, }, - props: { - sessions: { - type: Object, - default: () => { - return {} - }, - }, - }, setup() { const { isPublic } = useEditorFlags() - return { isPublic } - }, - data() { - return { - myName: '', - } + const { syncService } = useSyncService() + const { currentGuestSession, currentSession, sessions } = + useSessions(syncService) + return { currentGuestSession, currentSession, sessions, isPublic } }, computed: { label() { return t('text', 'Active people') }, - participantsPopover() { - if (this.currentSession?.guestName) { - return this.participantsWithoutCurrent - } - return this.participants - }, - participantsWithoutCurrent() { - return this.participants.filter((session) => !session.isCurrent) - }, - participants() { - return Object.values(this.sessions) - .filter( - (session) => - session.lastContact - > Date.now() / 1000 - COLLABORATOR_DISCONNECT_TIME - && (session.userId !== null || session.guestName !== null), - ) - .sort((a, b) => a.lastContact < b.lastContact) + sessionList() { + return this.showGuestNameDialog ? this.remoteSessions : this.sessions }, - showGuestNameDialog() { - return ( - this.isPublic && this.currentSession && !this.currentSession.userId + remoteSessions() { + return this.sessions.filter( + (session) => session.id !== this.currentSession?.id, ) }, - currentSession() { - return Object.values(this.sessions).find((session) => session.isCurrent) + showGuestNameDialog() { + return this.isPublic && this.currentGuestSession }, avatarStyle() { return (session) => { @@ -139,12 +104,19 @@ export default { } } }, - sessionsVisible() { - return this.participantsWithoutCurrent.slice(0, 3) + sessionsForTriggerButton() { + return this.remoteSessions.slice(0, 3) }, }, methods: { t, + displayNameOrGuestName: (session) => { + if (session.userId) { + return session.displayName + } + const guestName = session.guestName || t('text', 'Guest') + return `${guestName} (${t('text', 'guest')})` + }, }, } @@ -154,7 +126,8 @@ export default { height: var(--default-clickable-area); } -.avatar-list { +/* Needs to be more specific than 0,2,0 (NcButton) */ +.button-vue--icon-only.avatar-list { width: min-content !important; padding-inline: var(--default-grid-baseline); @@ -166,15 +139,15 @@ export default { .avatar-wrapper { margin: 3px -12px 3px 0; z-index: 1; - overflow: hidden; } } } .session-menu { - max-width: 280px; - padding-top: 6px; - padding-bottom: 6px; + --session-max-width: 280px; + max-width: var(--session-max-width); + padding-block-start: 6px; + padding-block-end: 6px; ul li { align-items: center; @@ -182,19 +155,24 @@ export default { padding: 6px; .avatar-wrapper { - height: 36px; - width: 36px; - margin-right: 6px; + margin-inline-end: 6px; } .session-label { - padding-right: 3px; + padding-inline-end: 3px; + /* keep some room for the avatar and edit button */ + max-width: calc(var(--session-max-width) - 60px); + overflow-wrap: break-word; } - .guest-label { - padding-left: 3px; + .guest-label, + .guest { color: var(--color-text-maxcontrast); } + + .guest-label { + padding-inline-start: 3px; + } } } diff --git a/src/components/Editor/Status.vue b/src/components/Editor/Status.vue index a51127f9200..3eb14318eb7 100644 --- a/src/components/Editor/Status.vue +++ b/src/components/Editor/Status.vue @@ -20,9 +20,7 @@ - +

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

@@ -59,11 +57,11 @@ export default { props: { hasConnectionIssue: { type: Boolean, - require: true, + required: true, }, dirty: { type: Boolean, - require: true, + required: true, }, document: { type: Object, @@ -73,12 +71,6 @@ export default { type: Object, default: null, }, - sessions: { - type: Object, - default: () => { - return {} - }, - }, }, setup() { @@ -135,7 +127,8 @@ export default { // Make this a dependent of refreshMoment, so it will be recomputed /* eslint-disable-next-line no-unused-expressions */ this.refreshMoment - return moment(this.document.lastSavedVersionTime * 1000).fromNow() + const timestamp = this.document?.lastSavedVersionTime + return timestamp ? moment(timestamp * 1000).fromNow() : '' }, }, diff --git a/src/composables/useConnection.ts b/src/composables/useConnection.ts index a18e854acb4..f6a6fb90acb 100644 --- a/src/composables/useConnection.ts +++ b/src/composables/useConnection.ts @@ -18,7 +18,7 @@ export interface Connection { export interface InitialData { document: Document - session: Session + session: Session & { token: string } readOnly: boolean content: string documentState?: string diff --git a/src/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index cf57b8c0899..cc1f34c9966 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -5,8 +5,10 @@ import type { Editor } from '@tiptap/core' import escapeHtml from 'escape-html' +import type { AwarenessUser } from '../extensions/CollaborationCursor.ts' import Markdown from '../extensions/Markdown.js' import markdownit from '../markdownit/index.js' +import { isUser, type Session } from '../services/SyncService' export const useEditorMethods = (editor: Editor) => { const setEditable = (val: boolean) => { @@ -34,5 +36,13 @@ export const useEditorMethods = (editor: Editor) => { .run() } - return { setContent, setEditable } + const updateUser = (session: Session) => { + const user: AwarenessUser = { + name: isUser(session) ? session.displayName : session?.guestName || '', + color: session?.color, + } + editor.commands.updateUser(user) + } + + return { setContent, setEditable, updateUser } } diff --git a/src/composables/useSessions.ts b/src/composables/useSessions.ts new file mode 100644 index 00000000000..d32ebdeba40 --- /dev/null +++ b/src/composables/useSessions.ts @@ -0,0 +1,98 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + computed, + onMounted, + onUnmounted, + readonly, + ref, + shallowRef, + watch, + type ShallowRef, +} from 'vue' +import type { OpenData } from '../apis/connect.js' +import { + COLLABORATOR_DISCONNECT_TIME, + isGuest, + SyncService, + type Session, +} from '../services/SyncService.js' +import { useConnection } from './useConnection.js' + +/** + * Get the sessions from the sync service. + * + * @param syncService to watch for changes to the sessions. + */ +export function useSessions(syncService: SyncService) { + const { openData } = useConnection() as { + openData: ShallowRef + } + const currentSession = ref(openData.value?.session) + const sessions = shallowRef([]) + + watch(openData, (val) => { + if (val?.session) { + currentSession.value = val.session + } + }) + + const currentGuestSession = computed({ + get() { + if (currentSession.value && isGuest(currentSession.value)) { + return currentSession.value + } + }, + set(newValue) { + currentSession.value = newValue + }, + }) + + const updateSessions = ({ sessions: updated }: { sessions: Session[] }) => { + const cutoff = Date.now() / 1000 - COLLABORATOR_DISCONNECT_TIME + sessions.value = updated + .filter((session) => session.lastContact > cutoff) + .sort((a, b) => b.lastContact - a.lastContact) + .filter(uniqueUserId) + + // Make sure we get our own session updated + const currentUpdatedSession = sessions.value.find( + (session) => session.id === currentSession.value?.id, + ) + if (currentUpdatedSession) { + currentSession.value = currentUpdatedSession + } + } + onMounted(() => { + syncService.bus.on('change', updateSessions) + }) + onUnmounted(() => { + syncService.bus.off('change', updateSessions) + }) + return { + currentGuestSession, + currentSession: readonly(currentSession), + sessions: readonly(sessions), + } +} + +/** + * Return true for entries with a unique user id or with no user id (Guests). + * + * To be used in filter. Will keep the first entry and remove duplicates. + * + * @param val the current value + * @param idx index of the current value + * @param arr the entire array + */ +function uniqueUserId(val: Session, idx: number, arr: Session[]): boolean { + if (!('userId' in val)) { + return true + } + return !arr + .slice(0, idx) + .some((session) => 'userId' in session && session.userId === val.userId) +} diff --git a/src/extensions/CollaborationCursor.ts b/src/extensions/CollaborationCursor.ts index 8e46022c794..a3e06b27c29 100644 --- a/src/extensions/CollaborationCursor.ts +++ b/src/extensions/CollaborationCursor.ts @@ -3,8 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { t } from '@nextcloud/l10n' import { CollaborationCursor as TiptapCollaborationCursor } from '@tiptap/extension-collaboration-cursor' +export interface AwarenessUser { + color: string + name: string +} + /** * @param clientId The Yjs client ID */ @@ -31,26 +37,31 @@ function getTimestamp() { return Math.floor(Date.now() / 1000) } +/** + * Render the cursor decoration + * + * @param user the users awareness data + * @param clientId not part of the tiptap type signature but provided by y-prosemirror + */ +function render(user: AwarenessUser, clientId?: number): HTMLElement { + const cursor = document.createElement('span') + cursor.classList.add('collaboration-cursor__caret') + cursor.setAttribute('style', `border-color: ${user.color}`) + const label = document.createElement('div') + label.classList.add('collaboration-cursor__label') + label.id = `collaboration-cursor__label__${clientId}` + label.setAttribute('style', `background-color: ${user.color}`) + const text = document.createTextNode(user.name || t('text', 'Guest')) + label.insertBefore(text, null) + cursor.insertBefore(label, null) + return cursor +} + const CollaborationCursor = TiptapCollaborationCursor.extend({ addOptions() { return { ...this.parent?.(), - render: (user) => { - const cursor = document.createElement('span') - - cursor.classList.add('collaboration-cursor__caret') - cursor.setAttribute('style', `border-color: ${user.color}`) - - const label = document.createElement('div') - - label.classList.add('collaboration-cursor__label') - label.id = `collaboration-cursor__label__${user.clientId}` - label.setAttribute('style', `background-color: ${user.color}`) - label.insertBefore(document.createTextNode(user.name), null) - cursor.insertBefore(label, null) - - return cursor - }, + render, } }, diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index df461572287..e9a90e43469 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -57,17 +57,41 @@ export interface Step { sessionId: number } -export interface Session { +export interface UserSession { id: number userId: string - token: string color: string lastAwarenessMessage: string lastContact: number - guestName?: string documentId: number displayName: string - clientId: number +} + +export interface GuestSession { + id: number + color: string + lastAwarenessMessage: string + lastContact: number + guestName: string + documentId: number +} + +export type Session = UserSession | GuestSession + +/** + * Test if a session is a guest session + * @param session to test. + */ +export function isGuest(session: Session): session is GuestSession { + return 'guestName' in session && typeof session.guestName === 'string' +} + +/** + * Test if a session is a logged in user session + * @param session to test. + */ +export function isUser(session: Session): session is UserSession { + return 'userId' in session && typeof session.userId === 'string' } export interface Document { @@ -95,7 +119,7 @@ export declare type EventTypes = { error: unknown /* Events for session and document meta data */ - change: unknown + change: { sessions: Session[]; document: Document } /* Emitted after successful save */ save: unknown @@ -262,7 +286,6 @@ class SyncService { sessions?: { lastContact: number lastAwarenessMessage: string - clientId: number }[] }) { const awareness = sessions @@ -273,7 +296,7 @@ class SyncService { ) .filter((s) => s.lastAwarenessMessage) .map((s) => { - return { step: s.lastAwarenessMessage, clientId: s.clientId } + return { step: s.lastAwarenessMessage } }) const newSteps = [...awareness] for (let i = 0; i < steps.length; i++) { @@ -289,7 +312,6 @@ class SyncService { singleSteps.forEach((step) => { newSteps.push({ step, - clientId: steps[i].sessionId, }) }) }