Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion dev/docs/yjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ id. The prefix still exists so two editors with incompatible Y.Doc layouts (Tipt
`Y.XmlFragment` vs CodeMirror's `Y.Text`) never share a room for the same file.

Awareness is anti-spoofed: `beforeHandleAwareness` overwrites the `user` field on every inbound awareness state with the
identity from the authenticated connection, so a client cannot present itself as someone else.
identity from the authenticated connection, so a client cannot present itself as someone else. Yjs never echoes a
client's own awareness back, so the `connected` hook sends each client its stamped identity over a stateless message.
The client puts it into its own `user` awareness field, which is how it shows up in the collaborator list next to its
peers.

## Inside `web`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
:icon-size="iconSize"
/>
</template>
<oc-avatar-count v-if="isOverlapping" :count="items.length - maxDisplayed" />
<oc-avatar-count v-if="isOverlapping" :count="items.length - maxDisplayed" :size="width" />
</span>
<span v-if="accessibleDescription" class="sr-only" v-text="accessibleDescription" />
</span>
Expand Down
11 changes: 9 additions & 2 deletions packages/web-pkg/src/components/Avatars/UserAvatar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<template>
<oc-avatar :user-name="userName" :src="avatarSrc" :width="width" />
<oc-avatar
:user-name="userName"
:src="avatarSrc"
:width="width"
:background-color="backgroundColor"
/>
</template>

<script setup lang="ts">
Expand All @@ -10,11 +15,13 @@ import { storeToRefs } from 'pinia'
const {
userId,
userName,
width = 36
width = 36,
backgroundColor = undefined
} = defineProps<{
userId: string
userName: string
width?: number
backgroundColor?: string
}>()

const avatarsStore = useAvatarsStore()
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/composables/yjs/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './useYjsSession'
export * from './types'
export * from './useYjsCollaborators'
103 changes: 103 additions & 0 deletions packages/web-pkg/src/composables/yjs/useYjsCollaborators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { onScopeDispose, shallowRef } from 'vue'
import type { Ref } from 'vue'
import type { Awareness } from 'y-protocols/awareness'

/** The `user` awareness field. The Yjs server stamps it on every peer state. */
export interface YjsAwarenessUser {
id: string
name: string
/** Hex color, shared by the remote cursor and the avatar in the toolbar. */
color: string
}

export interface YjsCollaborator extends YjsAwarenessUser {
isSelf: boolean
}

/**
* Prefix of the stateless message the Yjs server sends a connection with its
* own identity. Mirrors `IDENTITY_MESSAGE_PREFIX` in
* `services/yjs/src/lib/identity.ts`, keep the two in sync.
*/
export const IDENTITY_MESSAGE_PREFIX = '_oc_identity:'

/** Fallback when a state carries no color. Same as the remote cursor default. */
const DEFAULT_COLOR = '#ffa500'

/** Parses a server identity message. Null for any other stateless payload. */
export function decodeIdentityMessage(payload: string): YjsAwarenessUser | null {
if (!payload.startsWith(IDENTITY_MESSAGE_PREFIX)) return null
try {
const user: unknown = JSON.parse(payload.slice(IDENTITY_MESSAGE_PREFIX.length))
if (!isAwarenessUser(user)) return null
return normalizeUser(user)
} catch {
return null
}
}

function isAwarenessUser(value: unknown): value is YjsAwarenessUser {
if (!value || typeof value !== 'object') return false
const user = value as Partial<YjsAwarenessUser>
return typeof user.id === 'string' && user.id !== ''
}

function normalizeUser(user: YjsAwarenessUser): YjsAwarenessUser {
return {
id: user.id,
name: typeof user.name === 'string' ? user.name : '',
color: typeof user.color === 'string' ? user.color : DEFAULT_COLOR
}
}

/** One entry per user, so a user with several tabs open appears once. */
function readCollaborators(awareness: Awareness): YjsCollaborator[] {
const byId = new Map<string, YjsCollaborator>()
for (const [clientId, state] of awareness.getStates() as Map<number, Record<string, unknown>>) {
const user = state?.user
if (!isAwarenessUser(user)) continue
const isSelf = clientId === awareness.clientID
const existing = byId.get(user.id)
if (existing) {
if (isSelf) existing.isSelf = true
continue
}
byId.set(user.id, { ...normalizeUser(user), isSelf })
}
return Array.from(byId.values()).sort((a, b) => {
if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1
return a.name.localeCompare(b.name)
})
}

function signatureOf(collaborators: YjsCollaborator[]): string {
return collaborators.map((u) => `${u.id}|${u.name}|${u.color}|${u.isSelf}`).join('\n')
}

/**
* The people in the room, derived from the awareness states. The own user
* comes first, peers follow sorted by name. Bound to the awareness passed at
* setup time, like the cursor extension, and detached when the scope ends.
*/
export function useYjsCollaborators(awareness?: Awareness | null): Ref<YjsCollaborator[]> {
const collaborators = shallowRef<YjsCollaborator[]>([])
if (!awareness) return collaborators

const aw = awareness
let signature = ''

function update() {
const next = readCollaborators(aw)
const nextSignature = signatureOf(next)
// Awareness fires on every cursor move. Only publish when the people change.
if (nextSignature === signature) return
signature = nextSignature
collaborators.value = next
}

aw.on('change', update)
update()
onScopeDispose(() => aw.off('change', update), true)

return collaborators
}
8 changes: 8 additions & 0 deletions packages/web-pkg/src/composables/yjs/useYjsSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Resource } from '@opencloud-eu/web-client'
import { useGettext } from 'vue3-gettext'
import { useAuthStore, useConfigStore } from '../piniaStores'
import type { YjsAdapter } from './types'
import { decodeIdentityMessage } from './useYjsCollaborators'

export const YjsStatus = {
Connecting: 'connecting',
Expand Down Expand Up @@ -854,6 +855,13 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession {
void onProviderSynced(doc, null)
},
onStateless({ payload }) {
const identity = decodeIdentityMessage(payload)
if (identity) {
// The server never echoes our own awareness back to us, so we receive
// it via a stateless identity message instead.
prov.setAwarenessField('user', identity)
return
}
onSeedMessage(doc, prov, payload)
},
onSynced() {
Expand Down
104 changes: 104 additions & 0 deletions packages/web-pkg/src/editor/components/TextEditorCollaborators.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<div class="text-editor-toolbar-collaborators inline-flex items-center">
<oc-button
id="toolbar-collaborators-trigger"
v-oc-tooltip="label"
type="button"
appearance="raw"
gap-size="none"
class="text-editor-toolbar-collaborators-trigger rounded-full p-0.5"
:aria-label="label"
@mousedown.prevent
>
<oc-avatars
:items="avatarItems"
:width="24"
:max-displayed="3"
stacked
class="text-editor-collaborators-stack inline-flex"
>
<template #userAvatars="{ avatars }">
<span
v-for="(item, index) in avatars"
:key="item.userId"
class="text-editor-collaborator-avatar relative inline-flex rounded-full border-2"
:style="{ borderColor: colorById[item.userId], zIndex: avatars.length - index }"
:data-test-user-id="item.userId"
>
<user-avatar
:user-id="item.userId"
:user-name="item.displayName"
:width="20"
:background-color="colorById[item.userId]"
/>
</span>
</template>
</oc-avatars>
</oc-button>
<oc-drop
drop-id="toolbar-collaborators"
toggle="#toolbar-collaborators-trigger"
:teleport="teleport"
mode="click"
position="bottom-end"
padding-size="small"
enforce-drop-on-mobile
class="text-editor-toolbar-collaborators-drop"
>
<oc-list class="text-editor-collaborators-list">
<li
v-for="user in users"
:key="user.id"
class="text-editor-collaborators-item flex items-center gap-2 py-1"
:data-test-user-id="user.id"
>
<span
class="inline-flex shrink-0 rounded-full border-2"
:style="{ borderColor: user.color }"
>
<user-avatar
:user-id="user.id"
:user-name="user.name"
:width="28"
:background-color="user.color"
/>
</span>
<span class="truncate" v-text="user.name" />
<span
v-if="user.isSelf"
class="shrink-0 text-sm text-role-on-surface-variant"
v-text="$gettext('(you)')"
/>
</li>
</oc-list>
</oc-drop>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'
import type { YjsCollaborator } from '../../composables/yjs'
import UserAvatar from '../../components/Avatars/UserAvatar.vue'

const { users, teleport = 'body' } = defineProps<{
users: YjsCollaborator[]
teleport?: string
}>()

const { $gettext, $ngettext } = useGettext()

const avatarItems = computed(() =>
users.map((user) => ({ avatarType: 'user', userId: user.id, displayName: user.name }))
)
const colorById = computed(() => Object.fromEntries(users.map((user) => [user.id, user.color])))

const label = computed(() =>
$ngettext(
'%{count} person in this editing session',
'%{count} people in this editing session',
users.length,
{ count: users.length.toString() }
)
)
</script>
12 changes: 10 additions & 2 deletions packages/web-pkg/src/editor/components/TextEditorToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,16 @@
</oc-drop>
</div>
<div
v-if="showCollaborationStatusIndicator"
class="text-editor-toolbar-status flex shrink-0 items-center gap-1 px-4 ml-4"
v-if="showCollaborationStatusIndicator || collaborators.length"
class="text-editor-toolbar-status flex shrink-0 items-center gap-2 px-4 ml-4"
>
<text-editor-collaborators
v-if="collaborators.length"
:users="collaborators"
:teleport="dropTeleport"
/>
<div
v-if="showCollaborationStatusIndicator"
v-oc-tooltip="collaborationStatusLabel"
class="text-editor-toolbar-collaboration-status inline-flex items-center"
:aria-label="collaborationStatusLabel"
Expand Down Expand Up @@ -130,6 +136,7 @@ import type { TextEditorInstance } from '../types'
import type { EditorAction, EditorActionGroup } from '../composables'
import { OcBubbleMenu, OcDrop } from '@opencloud-eu/design-system/components'
import TextEditorToolbarItem from './TextEditorToolbarItem.vue'
import TextEditorCollaborators from './TextEditorCollaborators.vue'
import { isEditorActionEnabled } from '../helpers'
import { Key, Modifier, useKeyboardActions } from '../../composables/keyboardActions'
import { YjsStatus } from '../../composables/yjs'
Expand Down Expand Up @@ -383,6 +390,7 @@ const visible = computed(() => {
})

const yjsStatus = computed(() => unref(textEditor.yjsStatus))
const collaborators = computed(() => unref(textEditor.collaborators) ?? [])

const showCollaborationStatusIndicator = computed(() => {
const status = unref(textEditor.yjsStatus)
Expand Down
3 changes: 3 additions & 0 deletions packages/web-pkg/src/editor/composables/useTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Mentions, SlashCommands } from '../extensions'
import { stripColorFormattingFromPastedHtml } from '../helpers'
import { useContentStrategy } from './useContentStrategy'
import { useConfigStore } from '../../composables'
import { useYjsCollaborators } from '../../composables/yjs'

// Custom Tiptap extension that wires y-tiptap's yCursorPlugin to a given
// Awareness. We bypass `@tiptap/extension-collaboration-cursor` because
Expand Down Expand Up @@ -69,6 +70,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
const contentType = ref(options.contentType)
const readonly = computed(() => toValue(options.readonly) ?? false)
const yjsStatus = computed(() => toValue(options.yjsStatus) ?? null)
const collaborators = useYjsCollaborators(options.awareness)
const strategy = resolveStrategy(options.contentType, state)
const yjsFragment = options.ydocFragment ?? DEFAULT_YDOC_FRAGMENT

Expand Down Expand Up @@ -292,6 +294,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
contentType,
readonly,
yjsStatus,
collaborators,
actionGroups: editorActionGroups,
getContent,
setContent,
Expand Down
4 changes: 3 additions & 1 deletion packages/web-pkg/src/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Resource } from '@opencloud-eu/web-client'
import type { Editor } from '@tiptap/vue-3'
import type * as Y from 'yjs'
import type { Awareness } from 'y-protocols/awareness'
import type { YjsStatus } from '../composables/yjs'
import type { YjsCollaborator, YjsStatus } from '../composables/yjs'
import type { EditorActionGroup } from './composables'

export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json'
Expand Down Expand Up @@ -90,6 +90,8 @@ export interface TextEditorInstance {
readonly: Ref<boolean>
/** Current transport status of the hosting Yjs session, if any. */
yjsStatus: Ref<YjsStatus | null>
/** Users in the Yjs room, own user first. Empty without an awareness. */
collaborators: Ref<YjsCollaborator[]>
actionGroups(): EditorActionGroup[]
getContent(): string
setContent(value: string): void
Expand Down
Loading