From 6245a5a5a04281ce6f372b4155bb6a7bba5ac842 Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 4 Mar 2026 12:55:55 +0000 Subject: [PATCH] feat(docs): add collaborative document view to rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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> --- apps/web/res/css/_components.pcss | 1 + .../rooms/wysiwyg_composer/_DocumentView.pcss | 44 ++++ .../src/components/structures/RoomView.tsx | 10 + .../views/rooms/RoomHeader/RoomHeader.tsx | 27 +++ .../rooms/wysiwyg_composer/DocumentView.tsx | 218 ++++++++++++++++++ apps/web/src/contexts/RoomContext.ts | 2 + .../dispatcher/payloads/ViewRoomPayload.ts | 1 + apps/web/src/i18n/strings/en_EN.json | 6 +- apps/web/src/stores/RoomViewStore.tsx | 21 ++ 9 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss create mode 100644 apps/web/src/components/views/rooms/wysiwyg_composer/DocumentView.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index a788baa032..cf142aec79 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss b/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss new file mode 100644 index 0000000000..3c34ab2430 --- /dev/null +++ b/apps/web/res/css/views/rooms/wysiwyg_composer/_DocumentView.pcss @@ -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); + } + } +} diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index a8050571b2..a7ba4c155c 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -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 { 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 { {previewBar} ); + break; + } + case MainSplitContentType.Document: { + mainSplitContentClassName = "mx_MainSplit_document"; + mainSplitBody = this.state.room ? : undefined; + break; } } const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); diff --git a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx index b6c0086800..0851923736 100644 --- a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -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({ + 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 && } + + + + + + void; +} { + const [isLoaded, setIsLoaded] = useState(false); + const debounceTimer = useRef | 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
; + } + + return ( +
+
+ +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ +
+
+ ); +}); diff --git a/apps/web/src/contexts/RoomContext.ts b/apps/web/src/contexts/RoomContext.ts index c4a4d45c5d..b62cb18e47 100644 --- a/apps/web/src/contexts/RoomContext.ts +++ b/apps/web/src/contexts/RoomContext.ts @@ -28,6 +28,8 @@ export enum MainSplitContentType { Timeline, MaximisedWidget, Call, + /** Collaborative document editing view backed by Automerge. */ + Document, } export interface RoomContextType extends IRoomState { diff --git a/apps/web/src/dispatcher/payloads/ViewRoomPayload.ts b/apps/web/src/dispatcher/payloads/ViewRoomPayload.ts index c1dba33feb..84ba1134ff 100644 --- a/apps/web/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/apps/web/src/dispatcher/payloads/ViewRoomPayload.ts @@ -40,6 +40,7 @@ interface BaseViewRoomPayload extends Pick { 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 diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 9453131cfa..43229f43a2 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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", diff --git a/apps/web/src/stores/RoomViewStore.tsx b/apps/web/src/stores/RoomViewStore.tsx index 7d06391e32..d636c8a003 100644 --- a/apps/web/src/stores/RoomViewStore.tsx +++ b/apps/web/src/stores/RoomViewStore.tsx @@ -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. *