Skip to content

Commit eab262d

Browse files
authored
Add a Markdown viewer for .md attachments in the right panel (#35203)
* Add marked * Add the markdown viewer lab * Add the markdown viewer right panel phase * Add the open markdown viewer action * Detect markdown attachments * Render markdown to sanitized HTML * Add DocumentViewerView * Build the PDF viewer on DocumentViewerView * Add MarkdownViewerView * Add the markdown viewer * Show markdown attachments in the right panel * Pick the attachment viewer in one place * Offer to open markdown attachments from the timeline * Put both document viewers behind one lab * Drop the per-viewer lab flags * Update snapshots * Render markdown attachments with the message parser * Drop marked * Update snapshot * Keep the PDF viewer lab key for the shared lab * Add a document viewer registry with one media handle * Open every document through one right panel card * Type the lazy viewer loaders explicitly * Show an error when a document can no longer be opened
1 parent 0046435 commit eab262d

59 files changed

Lines changed: 1786 additions & 404 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
/**
9+
* The handle on an attachment that a document viewer needs: something to key per-file state on, and
10+
* a way to get at the bytes. `blob` is expected to decrypt transparently for encrypted media, so a
11+
* viewer never has to care whether the room is encrypted.
12+
*/
13+
export interface DocumentMedia {
14+
/** The MXC URI of the file. */
15+
uri: string;
16+
/** The file name, as sent. */
17+
name?: string;
18+
/** The size in bytes the sender declared for the file, if any. A claim, not a measurement. */
19+
size?: number;
20+
/** Resolves the file contents, decrypting first if the media is encrypted. */
21+
blob: () => Promise<Blob>;
22+
}

‎apps/web/src/@types/pdf-viewer.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,3 @@ export interface PdfViewerState {
2828
/** When this position was last recorded, in milliseconds since the epoch. Used to evict old entries. */
2929
updatedAt: number;
3030
}
31-
32-
/**
33-
* The handle on a PDF that `PdfViewer` needs: something to key the saved reading position on,
34-
* and a way to get at the bytes. `blob` is expected to decrypt transparently for encrypted media, so
35-
* the viewer never has to care whether the room is encrypted.
36-
*/
37-
export interface PdfMedia {
38-
/** The MXC URI of the file, used as the key for its saved reading position. */
39-
uri: string;
40-
/** The file name, as sent. */
41-
name?: string;
42-
/** The size in bytes the sender declared for the file, if any. A claim, not a measurement. */
43-
size?: number;
44-
/** Resolves the file contents, decrypting first if the media is encrypted. */
45-
blob: () => Promise<Blob>;
46-
}

‎apps/web/src/Markdown.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,17 @@ describe("Markdown parser test", () => {
174174
});
175175
});
176176
});
177+
178+
describe("Markdown soft breaks", () => {
179+
const wrapped = "First line\nsecond line\n\nNext paragraph";
180+
181+
it("turns soft breaks into line breaks by default, as messages want", () => {
182+
expect(new Markdown(wrapped).toHTML()).toEqual("<p>First line<br />second line</p>\n<p>Next paragraph</p>\n");
183+
});
184+
185+
it("keeps soft breaks as newlines when asked, as a wrapped document wants", () => {
186+
expect(new Markdown(wrapped).toHTML({ hardSoftBreaks: false })).toEqual(
187+
"<p>First line\nsecond line</p>\n<p>Next paragraph</p>\n",
188+
);
189+
});
190+
});

‎apps/web/src/Markdown.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,16 +275,20 @@ export default class Markdown {
275275
return true;
276276
}
277277

278-
public toHTML({ externalLinks = false } = {}): string {
278+
/**
279+
* Render the markdown to HTML.
280+
*
281+
* @param externalLinks - Open every link in a new tab, as a document viewer wants; messages
282+
* leave this off and decide per link at render time.
283+
* @param hardSoftBreaks - Turn a single newline inside a paragraph into a line break. Messages
284+
* want this, so that multi-line quotes stay multi-line
285+
* (https://github.com/vector-im/element-web/issues/3154). A document wrapped at a fixed
286+
* column does not, since every wrapped line would otherwise break.
287+
*/
288+
public toHTML({ externalLinks = false, hardSoftBreaks = true } = {}): string {
279289
const renderer = new commonmark.HtmlRenderer({
280290
safe: false,
281-
282-
// Set soft breaks to hard HTML breaks: commonmark
283-
// puts softbreaks in for multiple lines in a blockquote,
284-
// so if these are just newline characters then the
285-
// block quote ends up all on one line
286-
// (https://github.com/vector-im/element-web/issues/3154)
287-
softbreak: "<br />",
291+
softbreak: hardSoftBreaks ? "<br />" : "\n",
288292
});
289293

290294
// Trying to strip out the wrapping <p/> causes a lot more complication

‎apps/web/src/components/structures/RightPanel.test.tsx‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe("RightPanel", () => {
153153
expect(screen.getByRole("heading", { name: "r2" })).toBeInTheDocument();
154154
});
155155

156-
it("renders the PDF viewer card, named after the file, for the PdfViewer phase", async () => {
156+
it("renders the document viewer card, named after the file, for a PDF", async () => {
157157
const room = mkRoom(cli, "r1");
158158
cli.getRoom.mockImplementation((roomId) => (roomId === "r1" ? room : null));
159159

@@ -188,13 +188,57 @@ describe("RightPanel", () => {
188188
await rpsUpdated;
189189

190190
RightPanelStore.instance.setCard(
191-
{ phase: RightPanelPhases.PdfViewer, state: { pdfViewerEvent: pdfEvent } },
191+
{ phase: RightPanelPhases.DocumentViewer, state: { documentViewerEvent: pdfEvent } },
192192
true,
193193
"r1",
194194
);
195195

196196
// The viewer itself is code split, so the card header is what proves the phase was wired up
197-
// to PdfViewerCard with the right event.
197+
// to DocumentViewerCard with the right event.
198198
await waitFor(() => expect(screen.getByRole("heading", { name: "spec.pdf" })).toBeInTheDocument());
199199
});
200+
201+
it("renders the document viewer card, named after the file, for a Markdown file", async () => {
202+
const room = mkRoom(cli, "r1");
203+
cli.getRoom.mockImplementation((roomId) => (roomId === "r1" ? room : null));
204+
205+
const markdownEvent = new MatrixEvent({
206+
room_id: "r1",
207+
sender: "@user:example.org",
208+
event_id: "$markdown",
209+
type: EventType.RoomMessage,
210+
content: {
211+
body: "README.md",
212+
msgtype: MsgType.File,
213+
url: "mxc://example.org/readme",
214+
info: { mimetype: "text/markdown" },
215+
},
216+
});
217+
218+
// The card is only valid while the lab is on, so the store would otherwise drop it.
219+
await SettingsStore.setValue("feature_pdf_viewer", null, SettingLevel.DEVICE, true);
220+
221+
await spinUpStores();
222+
223+
render(
224+
<RightPanel
225+
room={room}
226+
resizeNotifier={resizeNotifier}
227+
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
228+
/>,
229+
);
230+
231+
const rpsUpdated = waitForRpsUpdate();
232+
dis.dispatch({ action: Action.ViewRoom, room_id: "r1" });
233+
await rpsUpdated;
234+
235+
RightPanelStore.instance.setCard(
236+
{ phase: RightPanelPhases.DocumentViewer, state: { documentViewerEvent: markdownEvent } },
237+
true,
238+
"r1",
239+
);
240+
241+
// As above: the header proves the card picked up this event.
242+
await waitFor(() => expect(screen.getByRole("heading", { name: "README.md" })).toBeInTheDocument());
243+
});
200244
});

‎apps/web/src/components/structures/RightPanel.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import TimelineCard from "../views/right_panel/TimelineCard";
3131
import { UPDATE_EVENT } from "../../stores/AsyncStore";
3232
import { type IRightPanelCard, type IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState";
3333
import { Action } from "../../dispatcher/actions";
34-
import { PdfViewerCard } from "../views/right_panel/PdfViewerCard";
34+
import { DocumentViewerCard } from "../views/right_panel/DocumentViewerCard";
3535
import { type XOR } from "../../@types/common";
3636
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
3737
import MemberListView from "../views/rooms/MemberList/MemberListView";
@@ -279,9 +279,9 @@ export default class RightPanel extends React.Component<Props, IState> {
279279
}
280280
break;
281281

282-
case RightPanelPhases.PdfViewer:
283-
if (!!cardState?.pdfViewerEvent) {
284-
card = <PdfViewerCard mxEvent={cardState.pdfViewerEvent} onClose={this.onClose} />;
282+
case RightPanelPhases.DocumentViewer:
283+
if (!!cardState?.documentViewerEvent) {
284+
card = <DocumentViewerCard mxEvent={cardState.documentViewerEvent} onClose={this.onClose} />;
285285
}
286286
break;
287287
}

‎apps/web/src/components/views/messages/MBodyFactory.test.tsx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,10 @@ describe("MBodyFactory", () => {
177177
});
178178
});
179179

180-
describe("FileBodyFactory and the PDF viewer lab", () => {
181-
afterEach(() => {
180+
describe("FileBodyFactory and the document previews lab, for a PDF", () => {
181+
afterEach(async () => {
182+
// Both viewers share the lab, so a value left on the device would leak into the next test.
183+
await SettingsStore.setValue("feature_pdf_viewer", null, SettingLevel.DEVICE, false);
182184
SettingsStore.reset();
183185
});
184186

@@ -205,6 +207,31 @@ describe("MBodyFactory", () => {
205207
});
206208
});
207209

210+
describe("FileBodyFactory and the document previews lab, for Markdown", () => {
211+
afterEach(async () => {
212+
// Both viewers share the lab, so a value left on the device would leak into the next test.
213+
await SettingsStore.setValue("feature_pdf_viewer", null, SettingLevel.DEVICE, false);
214+
SettingsStore.reset();
215+
});
216+
217+
it("offers the viewer only once the lab is turned on, without a remount", async () => {
218+
const mediaEvent = mkEvent("m.file", { body: "README.md", info: { mimetype: "text/markdown" } });
219+
220+
renderInRoomContext(
221+
renderMBody(
222+
{ ...props, mxEvent: mediaEvent, mediaEventHelper: new MediaEventHelper(mediaEvent) },
223+
FileBodyFactory,
224+
),
225+
TimelineRenderingType.Room,
226+
);
227+
expect(screen.queryByRole("button", { name: "Open Markdown" })).not.toBeInTheDocument();
228+
229+
await act(() => SettingsStore.setValue("feature_pdf_viewer", null, SettingLevel.DEVICE, true));
230+
231+
expect(screen.getByRole("button", { name: "Open Markdown" })).toBeInTheDocument();
232+
});
233+
});
234+
208235
it("renderMBody fallback shows m.audio generic placeholder when showFileInfo is true", async () => {
209236
const mediaEvent = new MatrixEvent({
210237
room_id: "!room:server",

‎apps/web/src/components/views/messages/MBodyFactory.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function LegacyFileBody({ mxEvent, mediaEventHelper, forExport, showFileInfo }:
5959
const { timelineRenderingType } = useContext(RoomContext);
6060
const refIFrame = useRef<HTMLIFrameElement>(null) as RefObject<HTMLIFrameElement>;
6161
const refLink = useRef<HTMLAnchorElement>(null) as RefObject<HTMLAnchorElement>;
62-
const pdfViewerEnabled = useSettingValue("feature_pdf_viewer");
62+
const documentPreviewsEnabled = useSettingValue("feature_pdf_viewer");
6363

6464
const vm = useCreateAutoDisposedViewModel(
6565
() =>
@@ -71,7 +71,7 @@ function LegacyFileBody({ mxEvent, mediaEventHelper, forExport, showFileInfo }:
7171
timelineRenderingType,
7272
refIFrame,
7373
refLink,
74-
pdfViewerEnabled,
74+
documentPreviewsEnabled,
7575
}),
7676
);
7777

@@ -82,9 +82,9 @@ function LegacyFileBody({ mxEvent, mediaEventHelper, forExport, showFileInfo }:
8282
forExport,
8383
showFileInfo,
8484
timelineRenderingType,
85-
pdfViewerEnabled,
85+
documentPreviewsEnabled,
8686
});
87-
}, [mxEvent, mediaEventHelper, forExport, showFileInfo, timelineRenderingType, pdfViewerEnabled, vm]);
87+
}, [mxEvent, mediaEventHelper, forExport, showFileInfo, timelineRenderingType, documentPreviewsEnabled, vm]);
8888

8989
return <FileBodyView vm={vm} refIFrame={refIFrame} refLink={refLink} className="mx_MFileBody" />;
9090
}
@@ -96,15 +96,15 @@ interface PreviewFileBodyProps {
9696

9797
/// the new preview file tile
9898
function PreviewFileBody({ mxEvent, mediaEventHelper }: PreviewFileBodyProps): JSX.Element {
99-
const pdfViewerEnabled = useSettingValue("feature_pdf_viewer");
99+
const documentPreviewsEnabled = useSettingValue("feature_pdf_viewer");
100100
const vm = useCreateAutoDisposedViewModel(
101-
() => new MBodyTileViewModel(mxEvent, mediaEventHelper, pdfViewerEnabled),
101+
() => new MBodyTileViewModel(mxEvent, mediaEventHelper, documentPreviewsEnabled),
102102
);
103103

104104
// The view model is built once, so turning the lab on has to reach an already-rendered tile.
105105
useEffect(() => {
106-
vm.setPdfViewerEnabled(pdfViewerEnabled);
107-
}, [pdfViewerEnabled, vm]);
106+
vm.setDocumentPreviewsEnabled(documentPreviewsEnabled);
107+
}, [documentPreviewsEnabled, vm]);
108108

109109
return (
110110
<div className="mx_EventTile_content">
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
// @vitest-environment happy-dom
9+
10+
import React from "react";
11+
import { beforeEach, describe, expect, it, vi } from "vitest";
12+
import { render, screen } from "test-utils-rtl";
13+
import userEvent from "@testing-library/user-event";
14+
import { EventType, type MatrixClient, MatrixEvent, MsgType, Room } from "matrix-js-sdk/src/matrix";
15+
16+
import { DocumentViewerCard } from "./DocumentViewerCard";
17+
import { clientAndSDKContextRenderOptions, stubClient, TestSDKContext } from "../../../test/test-utils";
18+
19+
function mkFileEvent(): MatrixEvent {
20+
return new MatrixEvent({
21+
room_id: "!room:example.org",
22+
sender: "@user:example.org",
23+
event_id: "$readme",
24+
type: EventType.RoomMessage,
25+
content: {
26+
body: "README.md",
27+
msgtype: MsgType.File,
28+
url: "mxc://example.org/readme",
29+
info: { mimetype: "text/markdown" },
30+
},
31+
});
32+
}
33+
34+
describe("DocumentViewerCard", () => {
35+
let client: MatrixClient;
36+
let sdkContext: TestSDKContext;
37+
38+
beforeEach(() => {
39+
client = stubClient();
40+
sdkContext = new TestSDKContext();
41+
sdkContext._client = client;
42+
});
43+
44+
it("names the card after the file and mounts its viewer", async () => {
45+
render(
46+
<DocumentViewerCard mxEvent={mkFileEvent()} onClose={vi.fn()} />,
47+
clientAndSDKContextRenderOptions(client, sdkContext),
48+
);
49+
50+
expect(screen.getByRole("heading", { name: "README.md" })).toBeInTheDocument();
51+
// The viewer is code split, so it shows up once its chunk has loaded.
52+
expect(await screen.findByRole("status")).toHaveTextContent("Loading document");
53+
});
54+
55+
it("shows an error that can be closed when the attachment can no longer be opened", async () => {
56+
const user = userEvent.setup();
57+
const onClose = vi.fn();
58+
const mxEvent = mkFileEvent();
59+
// A redaction while the card is open leaves an event no viewer handles.
60+
mxEvent.makeRedacted(mxEvent, new Room(mxEvent.getRoomId()!, client, client.getSafeUserId()));
61+
62+
render(
63+
<DocumentViewerCard mxEvent={mxEvent} onClose={onClose} />,
64+
clientAndSDKContextRenderOptions(client, sdkContext),
65+
);
66+
67+
expect(screen.getByRole("heading", { name: "Document" })).toBeInTheDocument();
68+
expect(screen.getByRole("alert")).toHaveTextContent("Unable to load this file.");
69+
70+
await user.click(screen.getByRole("button", { name: "Close" }));
71+
expect(onClose).toHaveBeenCalledOnce();
72+
});
73+
});

0 commit comments

Comments
 (0)