mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-06 04:36:21 +02:00
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:
parent
ac37bebf22
commit
6245a5a5a0
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
@ -28,6 +28,8 @@ export enum MainSplitContentType {
|
||||
Timeline,
|
||||
MaximisedWidget,
|
||||
Call,
|
||||
/** Collaborative document editing view backed by Automerge. */
|
||||
Document,
|
||||
}
|
||||
|
||||
export interface RoomContextType extends IRoomState {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user