- window.__docApplyDelta(base64) lets you manually inject a delta from
the browser console on the receiver tab, confirming whether applyDeltaBytes
itself works (delivery issue vs rendering issue)
- Log warning if onDeltaRef.current is null when injection is attempted
Previously both channels shared the same 500ms debounce, which defeated the
point of the low-latency RTC data channel.
New behaviour:
- RTC (LiveKit): fires on every call to scheduleDeltaSend using
save_after(lastRtcHeadsRef) to capture only the changes since the previous
RTC publish. Updated on each send so each message is a minimal delta.
- Matrix timeline: unchanged 500ms debounce using save_incremental(), which
has its own independent internal cursor unaffected by the RTC path.
Also advance lastRtcHeadsRef when remote deltas are applied (applyDeltaBytes)
and after the initial document load drain, so the RTC cursor never echoes
received or pre-existing content back to peers.
Previously, when LiveKit/MatrixRTC was connected the debounced send would
only go to the LiveKit data channel and skip Matrix timeline events entirely.
This meant data could be lost if the user exited before the 50 ms RTC debounce
fired, since the fire-and-forget snapshot on unmount is unreliable once the
browser begins unloading.
Changes:
- Single 500 ms debounce timer (replaces the separate 50 ms RTC / 500 ms Matrix
split). On each debounce tick:
1. save_incremental() captures pending local changes.
2. If LiveKit is connected, the delta is published via the data channel for
low-latency peer delivery.
3. The delta is ALWAYS sent as a Matrix timeline event (or
Previously, when LiveKit/MatrixRTC was connected the debounced send would
only go to the LiveKit data channel and skip Matrix timeline events entirely.
This meant data could be lost if the user exited before the 50 ms RTC debounce
fired, since the fire-and-forget snapshot on unmount is unreliable once the
browser begins unloadi- Aonly go to the LiveKit data channel and skip Matrix timeline events entiDoThis meant data could be lost if the user exited before the 50 ms RTC debounngfired, since the fire-and-forget snapshot on unmount is unreliable once the
b mbrowser begins unloading.
Changes:
- Single 500 ms debounce timer (replacd;
Changes:
- Single 500 ms
- Replace height:100% on .mx_DocumentView with flex:1 + min-height:0.
In the flex-column mx_RoomView_body, height:100% resolves to the full
parent height (not the remaining space), causing the total content to
exceed the container and flex-shrinking the 64px RoomHeader — making
the bottom border appear at the wrong vertical position. flex:1 takes
only the leftover space after the header.
- Add mx_RoomHeader_toggled class to DocumentIcon when isViewingDocument
is true, matching the same pattern used by ToggleableIcon for Threads,
Notifications, and RoomInfo buttons. The existing CSS rule
.mx_RoomHeader .mx_RoomHeader_toggled { fill: --cpd-color-icon-accent-primary }
then turns the icon green.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MatrixRTC transport discovery for MSC4143 is unstable-only and some
deployments still advertise foci via /.well-known/matrix/client
(org.matrix.msc4143.rtc_foci).
Try /unstable/org.matrix.msc4143/rtc/transports first, then fall back to
well-known so LiveKit can be used where configured.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After load_document() + receive_changes() replay, the Automerge incremental
save cursor is not advanced — so the next save_incremental() returns the
entire document history, not just new local edits. For a document with 90+
deltas this easily exceeds the Matrix 65KB encrypted event size limit.
Fix: call save_incremental() and discard the result:
1. After replaying timeline deltas on load (prevents 413 on first keystroke)
2. After receive_changes() in applyDeltaBytes (prevents remote changes from
being included in the next scheduled delta send)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace per-keystroke Matrix timeline events with a LiveKit data
channel for real-time Automerge delta delivery:
- Add livekit-client dependency
- New useDocumentRTC hook:
- Calls client._unstable_getRTCTransports() to get SFU URL
- Exchanges Matrix OpenID token with SFU (/sfu/get) for LiveKit JWT
- Connects to LiveKit room in data-only mode (autoSubscribe: false)
- Exposes publishDelta(bytes) and onDeltaRef callback
- Falls back gracefully if no LiveKit transport configured
- DocumentView wiring:
- useDocumentSync now accepts optional rtc transport
- When LiveKit connected: send deltas via data channel (50ms debounce)
- When LiveKit not available: fall back to Matrix timeline events (500ms)
- Matrix event listener disabled when LiveKit is connected
- Snapshots still go via Matrix state events (every 20 deltas + on close)
- __docDebug() now shows rtcConnected status
Benefits: no homeserver rate limiting on deltas, ~50ms latency vs
~500ms+sync, no delta events cluttering the room timeline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Exposes a lightweight function on window that returns CRDT heads, HTML,
doc hash, DOM HTML, timeline delta count/senders, and snapshot info.
Call it on both clients and compare to pinpoint divergence.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previous design saved a full snapshot to room state after every delta
send. This caused rate-limiting under rapid typing, meaning the snapshot
could be stale by many deltas. On refresh, only the snapshot was loaded
— any deltas not included were lost.
New design:
- Snapshots saved to room state only every 20 deltas (SNAPSHOT_EVERY_N_DELTAS)
- On unmount (close/navigate away), flush any pending delta and always
save a final snapshot so the state event stays reasonably fresh
- On load, after loading the snapshot from room state, replay all delta
timeline events newer than the snapshot timestamp. receive_changes()
is idempotent so re-applying already-merged deltas is a harmless no-op
- Include heads in snapshot content for future dedup optimization
This ensures no data is lost even if the snapshot is stale, and avoids
hammering the homeserver with state event updates.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two root causes of instability:
1. Receiver re-sends deltas it just received: MutationObserver callbacks
are async microtasks, so setting suppressMutations=false synchronously
after innerHTML= was too early — the observer callback hadn't fired
yet. Fix: defer the reset via requestAnimationFrame() so the flag is
still true when the observer's microtask runs.
2. Sender's own events echo back and rewrite DOM: in encrypted rooms
device_id is not reliably in event.getUnsigned(), so the per-device
skip check failed and the sender's own deltas were treated as remote.
This caused innerHTML to be set from get_content_as_html() which may
differ from the WASM useListeners DOM output (e.g. <p> vs <br> for
newlines), collapsing structure. Fix: skip by userId alone — the
local model already has our changes from save_incremental(), so
applying our own events is both unnecessary and harmful.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two fixes:
1. Caret reset on remote delta: when receive_changes() was applied and
innerHTML was set, the browser lost the caret position. Fix: save the
caret as a character offset before the update and restore it via a
TreeWalker walk after. A suppressMutations flag prevents the
MutationObserver from incorrectly scheduling a local delta send while
the remote HTML is being written.
2. Formatting/structural edits not sending deltas: onInput doesn't fire
for toolbar actions (bold, italic, heading, etc.) because those are
applied programmatically via the WASM model. Fix: attach a
MutationObserver to the contentEditable div that calls
scheduleDeltaSend() on any DOM change (childList, subtree,
characterData, attributes). The observer is suppressed during remote
innerHTML writes to avoid re-sending remote changes back.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In encrypted rooms, Room.timeline fires with type m.room.encrypted so the
org.element.doc.delta type check always fails. The event only has its real
type after decryption.
Fix: also listen on client MatrixEventEvent.Decrypted, which fires once the
event is fully decrypted with the correct type. Keep Room.timeline for
unencrypted rooms. Both listeners share the same applyDeltaEvent handler.
Since applyDeltaEvent is idempotent (receive_changes is a CRDT merge), it
is safe for an unencrypted event to be processed by both listeners.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The timeline listener was inside an effect gated on isCollaborative(composerModel).
When composerModel was null on first mount the effect returned early without
registering the listener. By the time the model became available the effect
re-ran — but onContentChanged had a new identity each render, causing the
listener to be torn down and re-added on every re-render.
Fix:
- Use a composerModelRef so the listener closure always reads the latest
model without the effect needing composerModel as a dependency.
- Register the listener unconditionally (guard inside the handler instead),
so it is set up on mount regardless of when the model initialises.
- Use a stable notifyContentChangedRef so the effect deps don't change,
preventing unnecessary listener churn.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three fixes:
1. Real-time remote updates: the sender check compared only userId, so
testing with the same account on two devices caused all events to be
skipped. Now also compares device_id from event.getUnsigned() so only
events from this exact device are skipped.
2. Placeholder persists: Editor always received the placeholder prop so
the ::before pseudo-element never disappeared. Now track hasContent
state (updated on input and on remote content load) and pass
placeholder={undefined} when the editor has content — matching how the
send composer conditionally hides its placeholder.
3. Text appearing below placeholder: same root cause as above — with the
placeholder ::before always present and the content in a block <p>,
text rendered on the line below the overlay. Removing the placeholder
class when content exists fixes the layout.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three bugs fixed:
1. Actor ID was passed as raw string (e.g. '@user:server:deviceId') but
set_actor_id() requires a hex-encoded byte string. Fix: encode the
userId:deviceId string as UTF-8 hex before passing to set_actor_id().
2. receive_changes() updated the Automerge CRDT model but the editor DOM
was never updated, so remote edits were invisible. Fix: after calling
receive_changes(), set editorRef.current.innerHTML to the result of
get_content_as_html().
3. No room state snapshot was saved after editing, so the document was
lost on refresh. Fix: after each successful delta send, also call
sendStateEvent with a full save_document() snapshot.
Also adds get_content_as_html() to the CollaborativeComposerModel interface
and passes editorRef into useDocumentSync.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Root cause of isWysiwygReady never becoming true: the early-return loading
state prevented the <Editor> from mounting, so ref.current was null when
useComposerModel's effect ran — which guards initModel() behind
'if (editorRef.current)'. With no element in the DOM, initAsync was never
awaited and composerModel stayed null forever.
Fix: always render the Editor regardless of isLoaded. The toolbar is still
hidden during loading; only the formatting buttons are deferred.
Also removes all temporary debug logging added during investigation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The RTE dist bundle imports @vector-im/matrix-wysiwyg-wasm as an external
dependency. Without this package resolving, initAsync never fires and
isWysiwygReady stays false permanently — keeping the editor disabled
(contentEditable=false) so focus() is silently ignored.
Fix: add pnpm override and manual symlink for @vector-im/matrix-wysiwyg-wasm
pointing to the local bindings/wysiwyg-wasm build.
Also adds temporary console logging to DocumentView to confirm click
handling, focus state, and editor readiness.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
LoggedInView has a global keydown handler that redirects any printable
character typed outside an input/contenteditable to the send-message
composer. When the document editor's outer wrapper div (tabIndex=-1) has
focus (i.e. before the first click reaches the contentEditable), this
handler fires and dispatches FocusSendMessageComposer, visibly stealing
all keystrokes.
Fix: skip the redirect when the active element is inside .mx_DocumentView.
Same guard added to the paste handler.
Also simplify handleContentClick to always call ref.current.focus() on
any click inside the content area, not just clicks that miss the editor
div — this ensures focus is reliably placed in the contentEditable even
when the click lands on a wrapper div.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three problems prevented the document editor from being usable:
1. The contentEditable div inherited composer sizing (~22px tall) so
clicks in the document content area landed outside it and bubbled up
to the mx_RoomView div[tabIndex=-1], making the whole room view flash
blue instead of focusing the editor. Fixed by making the Editor stack
fill the full content area height in _DocumentView.pcss.
2. useSetCursorPosition was not called, so even when the editor received
focus there was no selection range and no visible cursor. Added the
hook call (same pattern as WysiwygComposer).
3. Any click outside the now-tall contentEditable div (e.g. in padding)
still fell through. Added an onClick handler on the content wrapper
that calls ref.current.focus() when the click target isn't the editor
itself, ensuring any click in the document area focuses the editor.
Bonus: suppress the browser default outline on .mx_RoomView:focus so
pressing a key no longer shows a blue box around the entire room view.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Wrap DocumentView's Editor in ComposerContext.Provider so useSelection
(used internally by the Editor) has the required context, enabling
focus and keyboard input to work correctly.
- Add pnpm override pointing @vector-im/matrix-wysiwyg at the local
matrix-rich-text-editor/platforms/web build so the automerge
collaboration API (composerModel, useCollaboration, etc.) is available
when running element-web locally end-to-end.
- Remove the wysiwyg patchedDependency entry (superseded by the override).
- Update pnpm-lock.yaml to reflect the link resolution.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
* Update ghcr.io/element-hq/synapse:develop Docker digest to b256d74
* Update screenshot due to new API availability on Synapse
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---------
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
* refactor: move `DefaultTagID` and `TagID` to rls v3
Move the enum and type in rls v3 and update imports
* refactor: move `getChangedOverrideRoomMutePushRules` from rls to rls v3
* refactor: replace `VisiblityProvider` by `isRoomVisible` and move it to rls v3
* Add widget lifecycle API at top level
* Integrate while still falling back to the legacy api
* Remove WidgetKind
* Update module api
to the one that includes the new widget lifecycle api
* lint
* Make preload checks easier to understand
- Have single code path for preload checks.
- Remove duplicated logic for preapproveIdentity check
- Fix headers
* lint