feat(docs): add collaborative document view to rooms

Add a new 'Document' content type alongside Timeline/Call/Widget that
shows a full-room Automerge-backed collaborative editor.

Changes:
- Add `MainSplitContentType.Document` to RoomContext enum
- Add `view_document?: boolean` to ViewRoomPayload
- Track `viewingDocument` state in RoomViewStore with `isViewingDocument()`
- Update RoomView.getMainSplitContentType to return Document when active,
  and render <DocumentView> in the main split switch statement
- Add document toggle button (📄) to RoomHeader using the Compound
  IconButton / Tooltip pattern
- New DocumentView component:
  - Uses `useWysiwyg` for the rich text editor surface
  - Loads initial document from room-state event (org.element.doc.automerge)
  - Sends incremental Automerge deltas (debounced 500 ms) as
    org.element.doc.delta timeline events
  - Receives and applies remote deltas from other room participants
  - Full-height document layout with formatting toolbar
- New _DocumentView.pcss stylesheet + import in _components.pcss
- i18n strings: room.document.open / room.document.close

The collaboration methods (save_incremental, receive_changes, etc.) are
guarded by the isCollaborative() runtime type-check so the component
degrades gracefully with the current 2.40.0 npm package until the
langleyd/automerge build is published.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Langley 2026-03-04 12:55:55 +00:00
parent ac37bebf22
commit 6245a5a5a0
9 changed files with 329 additions and 1 deletions

View File

@ -317,6 +317,7 @@
@import "./views/rooms/_WhoIsTypingTile.pcss";
@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
@import "./views/rooms/wysiwyg_composer/_DocumentView.pcss";
@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss";

View File

@ -0,0 +1,44 @@
/*
Copyright 2026 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_DocumentView {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background-color: var(--cpd-color-bg-canvas-default);
}
.mx_DocumentView_loading {
/* Placeholder while the document is being loaded from room state */
}
.mx_DocumentView_toolbar {
display: flex;
align-items: center;
padding: var(--cpd-space-2x) var(--cpd-space-4x);
border-bottom: 1px solid var(--cpd-color-border-disabled);
flex-shrink: 0;
}
.mx_DocumentView_content {
flex: 1;
overflow-y: auto;
padding: var(--cpd-space-8x) var(--cpd-space-12x);
/* Give the editor enough room to feel like a document */
.mx_WysiwygComposer_Editor {
min-height: 100%;
.mx_WysiwygComposer_Editor_content {
min-height: 400px;
font-size: var(--cpd-font-size-body-lg);
line-height: 1.6;
caret-color: var(--cpd-color-text-primary);
}
}
}

View File

@ -70,6 +70,7 @@ import SettingsStore from "../../settings/SettingsStore";
import { Layout } from "../../settings/enums/Layout";
import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton";
import { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext";
import { DocumentView } from "../views/rooms/wysiwyg_composer/DocumentView";
import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils";
import { Action } from "../../dispatcher/actions";
import { type IMatrixClientCreds } from "../../MatrixClientPeg";
@ -603,6 +604,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) {
return MainSplitContentType.MaximisedWidget;
}
if (this.roomViewStore.isViewingDocument()) {
return MainSplitContentType.Document;
}
return MainSplitContentType.Timeline;
};
@ -2711,6 +2715,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{previewBar}
</>
);
break;
}
case MainSplitContentType.Document: {
mainSplitContentClassName = "mx_MainSplit_document";
mainSplitBody = this.state.room ? <DocumentView room={this.state.room} /> : undefined;
break;
}
}
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);

View File

@ -18,6 +18,7 @@ import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icon
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
import DocumentIcon from "@vector-im/compound-design-tokens/assets/web/icons/document";
import { HistoryVisibility, JoinRule, type Room } from "matrix-js-sdk/src/matrix";
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { Flex, Box } from "@element-hq/web-shared-components";
@ -51,6 +52,8 @@ import WithPresenceIndicator, { useDmMember } from "../../avatars/WithPresenceIn
import { type IOOBData } from "../../../../stores/ThreepidInviteStore.ts";
import { MainSplitContentType } from "../../../structures/RoomView.tsx";
import defaultDispatcher from "../../../../dispatcher/dispatcher.ts";
import { Action } from "../../../../dispatcher/actions.ts";
import { type ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload.ts";
import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog.tsx";
import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx";
import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx";
@ -291,10 +294,24 @@ function RoomHeaderButtons({
const roomContext = useScopedRoomContext("mainSplitContentType");
const isVideoRoom = calcIsVideoRoom(room);
const isViewingDocument = roomContext.mainSplitContentType === MainSplitContentType.Document;
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
const toggleDocumentView = useCallback(
(evt: React.MouseEvent): void => {
evt.stopPropagation();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: undefined,
view_document: !isViewingDocument,
});
},
[isViewingDocument, room.roomId],
);
return (
<>
{additionalButtons?.map((props) => {
@ -328,6 +345,16 @@ function RoomHeaderButtons({
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={isViewingDocument ? _t("room|document|close") : _t("room|document|open")}>
<IconButton
aria-label={isViewingDocument ? _t("room|document|close") : _t("room|document|open")}
aria-pressed={isViewingDocument}
onClick={toggleDocumentView}
>
<DocumentIcon />
</IconButton>
</Tooltip>
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}

View File

@ -0,0 +1,218 @@
/*
Copyright 2026 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { useWysiwyg, type UseWysiwyg } from "@vector-im/matrix-wysiwyg";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
import { FormattingButtons } from "./components/FormattingButtons.tsx";
import { Editor } from "./components/Editor.tsx";
/**
* Matrix event type for incremental Automerge deltas sent as timeline events.
* Content: { data: string (base64), heads: string[] }
*/
const DOC_DELTA_EVENT_TYPE = "org.element.doc.delta";
/**
* Matrix room-state key for the full Automerge document snapshot.
* Content: { data: string (base64) }
*/
const DOC_STATE_EVENT_TYPE = "org.element.doc.automerge";
/** Debounce delay (ms) before sending an incremental delta after a keystroke. */
const DELTA_DEBOUNCE_MS = 500;
// ------------------------------------------------------------------
// Collaboration type augmentation
//
// @vector-im/matrix-wysiwyg 2.40.0 does not yet expose the Automerge
// collaboration methods. We define the extended interfaces here so
// element-web can use them once the package is updated. At runtime
// isCollaborative() guards all calls.
// ------------------------------------------------------------------
interface CollaborativeComposerModel {
save_incremental(): Uint8Array;
save_document(): Uint8Array;
load_document(data: Uint8Array): void;
receive_changes(data: Uint8Array): unknown;
get_heads(): string[];
set_actor_id(actor: string): void;
}
/**
* UseWysiwyg extended with the composerModel field exposed in langleyd/automerge.
* The npm-published 2.40.0 package does not include this field; we use an
* intersection type + runtime cast so code is forward-compatible.
*/
type UseWysiwygExtended = UseWysiwyg & {
composerModel?: unknown;
};
function isCollaborative(model: unknown): model is CollaborativeComposerModel {
return typeof (model as CollaborativeComposerModel | null)?.save_incremental === "function";
}
function base64Encode(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64Decode(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// ------------------------------------------------------------------
// Hook: useDocumentSync
// ------------------------------------------------------------------
function useDocumentSync(
room: Room,
client: MatrixClient,
composerModel: unknown,
): {
isLoaded: boolean;
scheduleDeltaSend: () => void;
} {
const [isLoaded, setIsLoaded] = useState(false);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set actor ID to userId:deviceId for correct CRDT attribution.
useEffect(() => {
if (!isCollaborative(composerModel)) return;
const actorId = `${client.getUserId()}:${client.getDeviceId()}`;
try {
composerModel.set_actor_id(actorId);
} catch (e) {
logger.warn("[DocumentView] Failed to set actor ID", e);
}
}, [client, composerModel]);
// Load the initial document from room state if a snapshot exists.
useEffect(() => {
if (!isCollaborative(composerModel)) {
setIsLoaded(true);
return;
}
const stateEvent = room.currentState.getStateEvents(DOC_STATE_EVENT_TYPE, "");
if (stateEvent) {
const data = stateEvent.getContent<{ data?: string }>().data;
if (data) {
try {
composerModel.load_document(base64Decode(data));
logger.info("[DocumentView] Loaded document from room state");
} catch (e) {
logger.warn("[DocumentView] Failed to load document from room state", e);
}
}
}
setIsLoaded(true);
}, [room, composerModel]);
// Apply incoming delta events from the room timeline.
useEffect(() => {
if (!isCollaborative(composerModel)) return;
const onTimeline = (event: import("matrix-js-sdk/src/matrix").MatrixEvent): void => {
if (event.getRoomId() !== room.roomId) return;
if (event.getType() !== DOC_DELTA_EVENT_TYPE) return;
// Skip our own events the changes are already in the local model.
if (event.getSender() === client.getUserId()) return;
const data = event.getContent<{ data?: string }>().data;
if (!data) return;
try {
composerModel.receive_changes(base64Decode(data));
} catch (e) {
logger.warn("[DocumentView] Failed to apply remote delta", e);
}
};
// MatrixEvent is fired as "Room.timeline" on the Room object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
room.on("Room.timeline" as any, onTimeline);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return () => room.off("Room.timeline" as any, onTimeline);
}, [room, client, composerModel]);
// Debounced delta send triggered after each keystroke.
const scheduleDeltaSend = useCallback(() => {
if (!isCollaborative(composerModel)) return;
if (debounceTimer.current !== null) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(async () => {
debounceTimer.current = null;
if (!isCollaborative(composerModel)) return;
try {
const delta = composerModel.save_incremental();
if (delta.length === 0) return; // Nothing new to send.
const heads = composerModel.get_heads();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await client.sendEvent(room.roomId, DOC_DELTA_EVENT_TYPE as any, {
data: base64Encode(delta),
heads,
});
} catch (e) {
logger.warn("[DocumentView] Failed to send delta", e);
}
}, DELTA_DEBOUNCE_MS);
}, [client, composerModel, room.roomId]);
return { isLoaded, scheduleDeltaSend };
}
// ------------------------------------------------------------------
// DocumentView component
// ------------------------------------------------------------------
interface DocumentViewProps {
room: Room;
}
export const DocumentView = memo(function DocumentView({ room }: DocumentViewProps) {
const client = useMatrixClientContext();
// Cast to the extended type to access composerModel when available (requires
// @vector-im/matrix-wysiwyg >= langleyd/automerge build).
const wysiwygResult = useWysiwyg({ isAutoFocusEnabled: true }) as UseWysiwygExtended;
const { ref, isWysiwygReady, wysiwyg, actionStates } = wysiwygResult;
const composerModel = wysiwygResult.composerModel;
const { isLoaded, scheduleDeltaSend } = useDocumentSync(room, client, composerModel);
const handleInput = useCallback(() => {
scheduleDeltaSend();
}, [scheduleDeltaSend]);
if (!isLoaded) {
return <div className="mx_DocumentView mx_DocumentView_loading" />;
}
return (
<div className="mx_DocumentView" data-testid="DocumentView">
<div className="mx_DocumentView_toolbar">
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
</div>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className="mx_DocumentView_content" onInput={handleInput}>
<Editor ref={ref} disabled={!isWysiwygReady} placeholder="Start typing your document…" />
</div>
</div>
);
});

View File

@ -28,6 +28,8 @@ export enum MainSplitContentType {
Timeline,
MaximisedWidget,
Call,
/** Collaborative document editing view backed by Automerge. */
Document,
}
export interface RoomContextType extends IRoomState {

View File

@ -40,6 +40,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
view_call?: boolean; // Whether to view the call or call lobby for the room
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls)
view_document?: boolean; // Whether to show the collaborative document view for the room
opts?: JoinRoomPayload["opts"];
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action

View File

@ -2132,7 +2132,11 @@
},
"video_room": "This room is a video room",
"waiting_for_join_subtitle": "Once invited users have joined %(brand)s, you will be able to chat and the room will be end-to-end encrypted",
"waiting_for_join_title": "Waiting for users to join %(brand)s"
"waiting_for_join_title": "Waiting for users to join %(brand)s",
"document": {
"open": "Open document",
"close": "Close document"
}
},
"room_list": {
"add_room_label": "Add room",

View File

@ -109,6 +109,10 @@ interface State {
* Whether we're viewing a call or call lobby in this room
*/
viewingCall: boolean;
/**
* Whether we're viewing the collaborative document editor for this room
*/
viewingDocument: boolean;
promptAskToJoin: boolean;
@ -133,6 +137,7 @@ const INITIAL_STATE: State = {
viaServers: [],
wasContextSwitch: false,
viewingCall: false,
viewingDocument: false,
promptAskToJoin: false,
viewRoomOpts: { buttons: [] },
};
@ -231,6 +236,7 @@ export class RoomViewStore extends EventEmitter {
viaServers: [],
wasContextSwitch: false,
viewingCall: false,
viewingDocument: false,
});
break;
case Action.ViewRoomError:
@ -402,6 +408,7 @@ export class RoomViewStore extends EventEmitter {
viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch,
viewingCall: payload.view_call ?? false,
viewingDocument: payload.view_document ?? false,
});
// set this room as the room subscription. We need to await for it as this will fetch
// all room state for this room, which is required before we get the state below.
@ -431,6 +438,13 @@ export class RoomViewStore extends EventEmitter {
viaServers: payload.via_servers ?? [],
wasContextSwitch: payload.context_switch ?? false,
viewingCall,
// Preserve doc view when re-entering the same room; reset on room switch.
viewingDocument:
payload.view_document !== undefined
? payload.view_document
: payload.room_id === this.state.roomId
? this.state.viewingDocument
: false,
};
// Allow being given an event to be replied to when switching rooms but sanity check its for this room
@ -759,6 +773,13 @@ export class RoomViewStore extends EventEmitter {
return this.state.viewingCall;
}
/**
* Whether the collaborative document view is currently showing.
*/
public isViewingDocument(): boolean {
return this.state.viewingDocument;
}
/**
* Gets the current state of the 'promptForAskToJoin' property.
*