Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
44146cf
feat(draft): store Lexical drafts as one Loro working copy
Innei Oct 25, 2026
a72d3cb
feat(admin): edit Lexical drafts through the shared Loro doc
Innei Oct 25, 2026
34148ae
fix(admin): keep the idle checkpoint on the Loro path
Innei Oct 25, 2026
a93d1de
feat(draft): relay Loro updates and presence through a collab room
Innei Oct 25, 2026
6094ca6
feat(admin): sync the Loro doc over the collab room
Innei Oct 25, 2026
aa74868
feat(admin): show who else is editing and where
Innei Oct 25, 2026
fc6ff01
fix(collab): close join races and unauthenticated room access
Innei Oct 25, 2026
3d5b483
refactor(editor): share the Loro doc helpers as @mx-space/editor/loro
Innei Oct 25, 2026
531039e
feat(admin): land agent writes in the Loro doc as their own peer
Innei Oct 25, 2026
918369f
fix(admin): send agent writes and diff them against what the agent read
Innei Oct 25, 2026
f1e42b0
fix(admin): keep agent writes to the blocks it changed and to its own…
Innei Oct 25, 2026
03abc22
feat(collab): invite readers to co-author a document
Innei Oct 25, 2026
df04d07
feat(admin): co-author page, invite links and server-stamped presence
Innei Oct 25, 2026
b18107e
fix(admin): join the room when the socket opens during the first load
Innei Oct 25, 2026
ac6000f
fix(collab): make removing a co-author stick
Innei Oct 25, 2026
d78eda4
feat(admin): tell the owner that co-authors receive the edit history
Innei Oct 25, 2026
350cb01
fix(collab): keep removed co-authors' names on their history steps
Innei Oct 25, 2026
233e8f2
feat(admin): compact avatar stack for collaborators
Innei Oct 25, 2026
dea1849
fix(collab): refresh the owner's co-author list when a reader joins
Innei Oct 25, 2026
07ab1d3
Merge remote-tracking branch 'origin/master' into feat/loro-collab
Innei Oct 25, 2026
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
119 changes: 119 additions & 0 deletions apps/admin/src/api/collab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { deleteJson, getJson, patchJson, postJson, requestBytes } from './http'

export interface CollabInvite {
createdAt: string
expiresAt: string
id: string
maxUses: number | null
uses: number
}

export interface CreatedCollabInvite extends CollabInvite {
token: string
}

export interface Collaborator {
avatar: string | null
contributedAt: string | null
createdAt: string
name: string
readerId: string
removedAt: string | null
showInByline: boolean
}

export interface CollabPerson {
avatar: string | null
name: string
}

export interface CollabWorkspace {
collaborators: CollabPerson[]
documentId: string
draftId: string
me: CollabPerson & { id: string }
refType: string
}

export type AcceptedCollabInvite =
| { documentId: string; role: 'collaborator' }
| {
documentId: string
draftId: string
refId: string | null
refType: string
role: 'owner'
}

const documentPath = (documentId: string) => `/collab/documents/${documentId}`

export function listCollabInvites(documentId: string) {
return getJson<CollabInvite[]>(`${documentPath(documentId)}/invites`)
}

export function createCollabInvite(
documentId: string,
data: { expiresInDays?: number; maxUses?: number | null } = {},
) {
return postJson<CreatedCollabInvite, typeof data>(
`${documentPath(documentId)}/invites`,
data,
)
}

export function revokeCollabInvite(documentId: string, inviteId: string) {
return deleteJson<void>(`${documentPath(documentId)}/invites/${inviteId}`)
}

export function listCollaborators(documentId: string) {
return getJson<Collaborator[]>(`${documentPath(documentId)}/collaborators`)
}

export function updateCollaborator(
documentId: string,
readerId: string,
data: { showInByline: boolean },
) {
return patchJson<void, typeof data>(
`${documentPath(documentId)}/collaborators/${encodeURIComponent(readerId)}`,
data,
)
}

export function removeCollaborator(documentId: string, readerId: string) {
return deleteJson<void>(
`${documentPath(documentId)}/collaborators/${encodeURIComponent(readerId)}`,
)
}

export function acceptCollabInvite(token: string) {
return postJson<AcceptedCollabInvite, Record<string, never>>(
`/collab/invites/${encodeURIComponent(token)}/accept`,
{},
)
}

export function getCollabWorkspace(documentId: string) {
return getJson<CollabWorkspace>(documentPath(documentId))
}

const encodeBase64 = (bytes: Uint8Array) =>
btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''))

export function getCollabCrdt(documentId: string, since?: Uint8Array) {
const query = since ? `?since=${encodeURIComponent(encodeBase64(since))}` : ''
return requestBytes(`${documentPath(documentId)}/crdt${query}`, {
method: 'GET',
})
}

export async function postCollabCrdt(documentId: string, bytes: Uint8Array) {
await requestBytes(`${documentPath(documentId)}/crdt`, {
body: bytes as Uint8Array<ArrayBuffer>,
headers: { 'content-type': 'application/octet-stream' },
method: 'POST',
})
}

export const collabInviteUrlOf = (token: string) =>
`${window.location.origin}${window.location.pathname}#/collab/join/${token}`
63 changes: 62 additions & 1 deletion apps/admin/src/api/drafts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Image, PaginateResult } from '~/models/base'
import type {
ContentRevision,
CrdtOpId,
DraftFields,
DraftHistory,
DraftModel,
DraftRefType,
DraftShare,
Expand All @@ -9,7 +12,14 @@ import type {
VersionContext,
} from '~/models/draft'

import { deleteJson, getJson, postJson, putJson } from './http'
import {
deleteJson,
getJson,
patchJson,
postJson,
putJson,
requestBytes,
} from './http'

export type DraftSortOrder = 'asc' | 'desc'

Expand Down Expand Up @@ -112,3 +122,54 @@ export function updateDraft(id: string, data: UpdateDraftData) {
export function deleteDraft(id: string) {
return deleteJson<{ success: boolean }>(`/drafts/${id}`)
}

const encodeBase64 = (bytes: Uint8Array) =>
btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''))

export function getDraftCrdt(id: string, since?: Uint8Array) {
const query = since ? `?since=${encodeURIComponent(encodeBase64(since))}` : ''
return requestBytes(`/drafts/${id}/crdt${query}`, { method: 'GET' })
}

export async function postDraftCrdt(id: string, bytes: Uint8Array) {
await requestBytes(`/drafts/${id}/crdt`, {
body: bytes as Uint8Array<ArrayBuffer>,
headers: { 'content-type': 'application/octet-stream' },
method: 'POST',
})
}

export function createDraftCheckpoint(id: string, kind: 'publish' | 'save') {
return postJson<DraftModel, { kind: 'publish' | 'save' }>(
`/drafts/${id}/checkpoints`,
{ kind },
)
}

export function updateDraftFields(
id: string,
patch: Partial<Record<keyof DraftFields, Record<string, unknown>>>,
) {
return patchJson<DraftFields, typeof patch>(`/drafts/${id}/fields`, patch)
}

export function getDraftHistory(id: string) {
return getJson<DraftHistory>(`/drafts/${id}/history`)
}

export function restoreDraftRevision(id: string, revisionId: string) {
return postJson<DraftModel, { revisionId: string }>(`/drafts/${id}/restore`, {
revisionId,
})
}

export function undoDraftChange(
id: string,
change: CrdtOpId,
before: CrdtOpId[],
) {
return postJson<DraftModel, { before: CrdtOpId[]; id: CrdtOpId }>(
`/drafts/${id}/undo`,
{ before, id: change },
)
}
35 changes: 34 additions & 1 deletion apps/admin/src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ export async function requestJson<TResponse>(
return responseData as TResponse
}

export async function requestBytes(
path: string,
init: RequestInit = {},
): Promise<Uint8Array> {
const response = await fetch(`${API_URL}${path}`, {
credentials: 'include',
...init,
headers: buildAdminRequestHeaders(init.headers),
})

if (!response.ok) {
const responseData = normalizeResponseData(
camelcaseKeys(await readResponseData<unknown>(response)),
)
if (isUnauthorizedResponse(response, responseData)) {
handleUnauthorized()
}
const message =
responseData?.error?.message ||
responseData?.message ||
response.statusText
throw new ApiRequestError(
Array.isArray(message) ? message.join(', ') : message || 'Request failed',
responseData?.error?.code ?? responseData?.code,
response.status,
responseData?.error?.details,
)
}

return new Uint8Array(await response.arrayBuffer())
}

export async function requestBlob(
path: string,
init: RequestInit = {},
Expand Down Expand Up @@ -184,7 +216,8 @@ function handleUnauthorized() {
const isAuthRoute =
hash.startsWith('/login') ||
hash.startsWith('/setup') ||
hash.startsWith('/setup-api')
hash.startsWith('/setup-api') ||
hash.startsWith('/collab')

if (isAuthRoute) return

Expand Down
64 changes: 64 additions & 0 deletions apps/admin/src/features/collab/components/CoauthorEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { LexicalEditor } from 'lexical'
import { lazy, Suspense, useEffect, useState } from 'react'

import { API_URL } from '~/constants/env'
import { RemoteCarets } from '~/features/write/components/collab/RemoteCarets'
import type { CrdtDraftController } from '~/features/write/crdt/use-crdt-draft'
import { useI18n } from '~/i18n'

const RichEditorBridge = lazy(() =>
import('~/vendor/rich-editor').then((module) => ({
default: module.RichEditorBridge,
})),
)

const colorScheme = () =>
document.documentElement.classList.contains('dark') ? 'dark' : 'light'

const refuseSnapshot = async (): Promise<string> => {
throw new Error('Co-authors cannot upload drawings')
}

export function CoauthorEditor(props: {
crdt: CrdtDraftController
variant: 'article' | 'note'
}) {
const { t } = useI18n()
const [editor, setEditor] = useState<LexicalEditor | null>(null)
const { bindEditor, locked } = props.crdt
useEffect(() => {
editor?.setEditable(!locked)
}, [editor, locked])

return (
<>
<Suspense
fallback={
<div className="h-32 animate-pulse rounded-md bg-surface-inset" />
}
>
<RichEditorBridge
apiUrl={API_URL}
editorProps={{
className: 'min-h-[60dvh] bg-transparent',
contentClassName: 'min-h-[60dvh] px-0 pt-3 pb-[200px]',
debounceMs: 250,
placeholder: t('write.richEditor.placeholder'),
theme: colorScheme(),
variant: props.variant,
}}
onEditorReady={(next) => {
setEditor(next)
bindEditor(next)
}}
saveExcalidrawSnapshot={refuseSnapshot}
/>
</Suspense>
<RemoteCarets
editor={editor}
peers={props.crdt.peers}
resolveCaret={props.crdt.resolveCaret}
/>
</>
)
}
Loading
Loading