Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 70665d3

Browse files
RTE drafts (#12674)
* Add drafts to the RTE and tests * test drafts in threads * lint * Add unit test. * Fix test failure * Remove unused import * Clean up wysiwyg drafts and add test. * Fix typo * Add timeout to allow for wasm loading. --------- Co-authored-by: Florian Duros <[email protected]>
1 parent fdc5acd commit 70665d3

File tree

5 files changed

+266
-38
lines changed

5 files changed

+266
-38
lines changed

playwright/e2e/composer/RTE.spec.ts

+105
Original file line numberDiff line numberDiff line change
@@ -249,5 +249,110 @@ test.describe("Composer", () => {
249249
);
250250
});
251251
});
252+
253+
test.describe("Drafts", () => {
254+
test("drafts with rich and plain text", async ({ page, app }) => {
255+
// Set up a second room to swtich to, to test drafts
256+
const firstRoomname = "Composing Room";
257+
const secondRoomname = "Second Composing Room";
258+
await app.client.createRoom({ name: secondRoomname });
259+
260+
// Composer is visible
261+
const composer = page.locator("div[contenteditable=true]");
262+
await expect(composer).toBeVisible();
263+
264+
// Type some formatted text
265+
await composer.pressSequentially("my ");
266+
await composer.press(`${CtrlOrMeta}+KeyB`);
267+
await composer.pressSequentially("bold");
268+
269+
// Change to plain text mode
270+
await page.getByRole("button", { name: "Hide formatting" }).click();
271+
272+
// Change to another room and back again
273+
await app.viewRoomByName(secondRoomname);
274+
await app.viewRoomByName(firstRoomname);
275+
276+
// assert the markdown
277+
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
278+
279+
// Change to plain text mode and assert the markdown
280+
await page.getByRole("button", { name: "Show formatting" }).click();
281+
282+
// Change to another room and back again
283+
await app.viewRoomByName(secondRoomname);
284+
await app.viewRoomByName(firstRoomname);
285+
286+
// Send the message and assert the message
287+
await page.getByRole("button", { name: "Send message" }).click();
288+
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
289+
});
290+
291+
test("draft with replies", async ({ page, app }) => {
292+
// Set up a second room to swtich to, to test drafts
293+
const firstRoomname = "Composing Room";
294+
const secondRoomname = "Second Composing Room";
295+
await app.client.createRoom({ name: secondRoomname });
296+
297+
// Composer is visible
298+
const composer = page.locator("div[contenteditable=true]");
299+
await expect(composer).toBeVisible();
300+
301+
// Send a message
302+
await composer.pressSequentially("my first message");
303+
await page.getByRole("button", { name: "Send message" }).click();
304+
305+
// Click reply
306+
const tile = page.locator(".mx_EventTile_last");
307+
await tile.hover();
308+
await tile.getByRole("button", { name: "Reply", exact: true }).click();
309+
310+
// Type reply text
311+
await composer.pressSequentially("my reply");
312+
313+
// Change to another room and back again
314+
await app.viewRoomByName(secondRoomname);
315+
await app.viewRoomByName(firstRoomname);
316+
317+
// Assert reply mode and reply text
318+
await expect(page.getByText("Replying")).toBeVisible();
319+
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
320+
});
321+
322+
test("draft in threads", async ({ page, app }) => {
323+
// Set up a second room to swtich to, to test drafts
324+
const firstRoomname = "Composing Room";
325+
const secondRoomname = "Second Composing Room";
326+
await app.client.createRoom({ name: secondRoomname });
327+
328+
// Composer is visible
329+
const composer = page.locator("div[contenteditable=true]");
330+
await expect(composer).toBeVisible();
331+
332+
// Send a message
333+
await composer.pressSequentially("my first message");
334+
await page.getByRole("button", { name: "Send message" }).click();
335+
336+
// Click reply
337+
const tile = page.locator(".mx_EventTile_last");
338+
await tile.hover();
339+
await tile.getByRole("button", { name: "Reply in thread" }).click();
340+
341+
const thread = page.locator(".mx_ThreadView");
342+
const threadComposer = thread.locator("div[contenteditable=true]");
343+
344+
// Type threaded text
345+
await threadComposer.pressSequentially("my threaded message");
346+
347+
// Change to another room and back again
348+
await app.viewRoomByName(secondRoomname);
349+
await app.viewRoomByName(firstRoomname);
350+
351+
// Assert threaded draft
352+
await expect(
353+
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
354+
).toBeVisible();
355+
});
356+
});
252357
});
253358
});

src/DraftCleaner.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger";
1818

1919
import { MatrixClientPeg } from "./MatrixClientPeg";
2020
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer";
21+
import { WYSIWYG_EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/MessageComposer";
2122

2223
// The key used to persist the the timestamp we last cleaned up drafts
2324
export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup";
@@ -61,14 +62,21 @@ function shouldCleanupDrafts(): boolean {
6162
}
6263

6364
/**
64-
* Clear all drafts for the CIDER editor if the room does not exist in the known rooms.
65+
* Clear all drafts for the CIDER and WYSIWYG editors if the room does not exist in the known rooms.
6566
*/
6667
function cleaupDrafts(): void {
6768
for (let i = 0; i < localStorage.length; i++) {
6869
const keyName = localStorage.key(i);
69-
if (!keyName?.startsWith(EDITOR_STATE_STORAGE_PREFIX)) continue;
70+
if (!keyName) continue;
71+
let roomId: string | undefined = undefined;
72+
if (keyName.startsWith(EDITOR_STATE_STORAGE_PREFIX)) {
73+
roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
74+
}
75+
if (keyName.startsWith(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX)) {
76+
roomId = keyName.slice(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
77+
}
78+
if (!roomId) continue;
7079
// Remove the prefix and the optional event id suffix to leave the room id
71-
const roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
7280
const room = MatrixClientPeg.safeGet().getRoom(roomId);
7381
if (!room) {
7482
logger.debug(`Removing draft for unknown room with key ${keyName}`);

src/components/views/rooms/MessageComposer.tsx

+84-5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "matrix-js-sdk/src/matrix";
2727
import { Optional } from "matrix-events-sdk";
2828
import { Tooltip } from "@vector-im/compound-web";
29+
import { logger } from "matrix-js-sdk/src/logger";
2930

3031
import { _t } from "../../../languageHandler";
3132
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -65,6 +66,9 @@ import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStart
6566
import { UIFeature } from "../../../settings/UIFeature";
6667
import { formatTimeLeft } from "../../../DateUtils";
6768

69+
// The prefix used when persisting editor drafts to localstorage.
70+
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
71+
6872
let instanceCount = 0;
6973

7074
interface ISendButtonProps {
@@ -109,6 +113,12 @@ interface IState {
109113
initialComposerContent: string;
110114
}
111115

116+
type WysiwygComposerState = {
117+
content: string;
118+
isRichText: boolean;
119+
replyEventId?: string;
120+
};
121+
112122
export class MessageComposer extends React.Component<IProps, IState> {
113123
private dispatcherRef?: string;
114124
private messageComposerInput = createRef<SendMessageComposerClass>();
@@ -129,21 +139,42 @@ export class MessageComposer extends React.Component<IProps, IState> {
129139

130140
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
131141
super(props, context);
142+
this.context = context; // otherwise React will only set it prior to render due to type def above
143+
132144
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
133145

146+
window.addEventListener("beforeunload", this.saveWysiwygEditorState);
147+
const isWysiwygLabEnabled = SettingsStore.getValue<boolean>("feature_wysiwyg_composer");
148+
let isRichTextEnabled = true;
149+
let initialComposerContent = "";
150+
if (isWysiwygLabEnabled) {
151+
const wysiwygState = this.restoreWysiwygEditorState();
152+
if (wysiwygState) {
153+
isRichTextEnabled = wysiwygState.isRichText;
154+
initialComposerContent = wysiwygState.content;
155+
if (wysiwygState.replyEventId) {
156+
dis.dispatch({
157+
action: "reply_to_event",
158+
event: this.props.room.findEventById(wysiwygState.replyEventId),
159+
context: this.context.timelineRenderingType,
160+
});
161+
}
162+
}
163+
}
164+
134165
this.state = {
135-
isComposerEmpty: true,
136-
composerContent: "",
166+
isComposerEmpty: initialComposerContent?.length === 0,
167+
composerContent: initialComposerContent,
137168
haveRecording: false,
138169
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
139170
isMenuOpen: false,
140171
isStickerPickerOpen: false,
141172
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
142173
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
143174
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
144-
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"),
145-
isRichTextEnabled: true,
146-
initialComposerContent: "",
175+
isWysiwygLabEnabled: isWysiwygLabEnabled,
176+
isRichTextEnabled: isRichTextEnabled,
177+
initialComposerContent: initialComposerContent,
147178
};
148179

149180
this.instanceId = instanceCount++;
@@ -154,6 +185,52 @@ export class MessageComposer extends React.Component<IProps, IState> {
154185
SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
155186
}
156187

188+
private get editorStateKey(): string {
189+
let key = WYSIWYG_EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
190+
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
191+
key += `_${this.props.relation.event_id}`;
192+
}
193+
return key;
194+
}
195+
196+
private restoreWysiwygEditorState(): WysiwygComposerState | undefined {
197+
const json = localStorage.getItem(this.editorStateKey);
198+
if (json) {
199+
try {
200+
const state: WysiwygComposerState = JSON.parse(json);
201+
return state;
202+
} catch (e) {
203+
logger.error(e);
204+
}
205+
}
206+
return undefined;
207+
}
208+
209+
private saveWysiwygEditorState = (): void => {
210+
if (this.shouldSaveWysiwygEditorState()) {
211+
const { isRichTextEnabled, composerContent } = this.state;
212+
const replyEventId = this.props.replyToEvent ? this.props.replyToEvent.getId() : undefined;
213+
const item: WysiwygComposerState = {
214+
content: composerContent,
215+
isRichText: isRichTextEnabled,
216+
replyEventId: replyEventId,
217+
};
218+
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
219+
} else {
220+
this.clearStoredEditorState();
221+
}
222+
};
223+
224+
// should save state when wysiwyg is enabled and has contents or reply is open
225+
private shouldSaveWysiwygEditorState = (): boolean => {
226+
const { isWysiwygLabEnabled, isComposerEmpty } = this.state;
227+
return isWysiwygLabEnabled && (!isComposerEmpty || !!this.props.replyToEvent);
228+
};
229+
230+
private clearStoredEditorState(): void {
231+
localStorage.removeItem(this.editorStateKey);
232+
}
233+
157234
private get voiceRecording(): Optional<VoiceMessageRecording> {
158235
return this._voiceRecording;
159236
}
@@ -265,6 +342,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
265342
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
266343
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
267344

345+
window.removeEventListener("beforeunload", this.saveWysiwygEditorState);
346+
this.saveWysiwygEditorState();
268347
// clean up our listeners by setting our cached recording to falsy (see internal setter)
269348
this.voiceRecording = null;
270349
}

test/components/structures/MatrixChat-test.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,18 @@ describe("<MatrixChat />", () => {
624624
expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).toBeNull();
625625
});
626626

627+
it("should clean up wysiwyg drafts", async () => {
628+
Date.now = jest.fn(() => timestamp);
629+
localStorage.setItem(`mx_wysiwyg_state_${roomId}`, "fake_content");
630+
localStorage.setItem(`mx_wysiwyg_state_${unknownRoomId}`, "fake_content");
631+
await getComponentAndWaitForReady();
632+
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing);
633+
// let things settle
634+
await flushPromises();
635+
expect(localStorage.getItem(`mx_wysiwyg_state_${roomId}`)).not.toBeNull();
636+
expect(localStorage.getItem(`mx_wysiwyg_state_${unknownRoomId}`)).toBeNull();
637+
});
638+
627639
it("should not clean up drafts before expiry", async () => {
628640
// Set the last cleanup to the recent past
629641
localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content");

0 commit comments

Comments
 (0)