Skip to content

Commit

Permalink
RTE drafts (#12674)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
langleyd and florianduros authored Aug 22, 2024
1 parent fdc5acd commit 70665d3
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 38 deletions.
105 changes: 105 additions & 0 deletions playwright/e2e/composer/RTE.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,110 @@ test.describe("Composer", () => {
);
});
});

test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });

// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();

// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");

// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();

// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);

// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();

// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();

// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);

// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});

test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });

// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();

// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();

// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();

// Type reply text
await composer.pressSequentially("my reply");

// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);

// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});

test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });

// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();

// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();

// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();

const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");

// Type threaded text
await threadComposer.pressSequentially("my threaded message");

// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);

// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});
14 changes: 11 additions & 3 deletions src/DraftCleaner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger";

import { MatrixClientPeg } from "./MatrixClientPeg";
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer";
import { WYSIWYG_EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/MessageComposer";

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

/**
* Clear all drafts for the CIDER editor if the room does not exist in the known rooms.
* Clear all drafts for the CIDER and WYSIWYG editors if the room does not exist in the known rooms.
*/
function cleaupDrafts(): void {
for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(i);
if (!keyName?.startsWith(EDITOR_STATE_STORAGE_PREFIX)) continue;
if (!keyName) continue;
let roomId: string | undefined = undefined;
if (keyName.startsWith(EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (keyName.startsWith(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (!roomId) continue;
// Remove the prefix and the optional event id suffix to leave the room id
const roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
logger.debug(`Removing draft for unknown room with key ${keyName}`);
Expand Down
89 changes: 84 additions & 5 deletions src/components/views/rooms/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import { Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";

import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
Expand Down Expand Up @@ -65,6 +66,9 @@ import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStart
import { UIFeature } from "../../../settings/UIFeature";
import { formatTimeLeft } from "../../../DateUtils";

// The prefix used when persisting editor drafts to localstorage.
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";

let instanceCount = 0;

interface ISendButtonProps {
Expand Down Expand Up @@ -109,6 +113,12 @@ interface IState {
initialComposerContent: string;
}

type WysiwygComposerState = {
content: string;
isRichText: boolean;
replyEventId?: string;
};

export class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>();
Expand All @@ -129,21 +139,42 @@ export class MessageComposer extends React.Component<IProps, IState> {

public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.context = context; // otherwise React will only set it prior to render due to type def above

VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);

window.addEventListener("beforeunload", this.saveWysiwygEditorState);
const isWysiwygLabEnabled = SettingsStore.getValue<boolean>("feature_wysiwyg_composer");
let isRichTextEnabled = true;
let initialComposerContent = "";
if (isWysiwygLabEnabled) {
const wysiwygState = this.restoreWysiwygEditorState();
if (wysiwygState) {
isRichTextEnabled = wysiwygState.isRichText;
initialComposerContent = wysiwygState.content;
if (wysiwygState.replyEventId) {
dis.dispatch({
action: "reply_to_event",
event: this.props.room.findEventById(wysiwygState.replyEventId),
context: this.context.timelineRenderingType,
});
}
}
}

this.state = {
isComposerEmpty: true,
composerContent: "",
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
isStickerPickerOpen: false,
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"),
isRichTextEnabled: true,
initialComposerContent: "",
isWysiwygLabEnabled: isWysiwygLabEnabled,
isRichTextEnabled: isRichTextEnabled,
initialComposerContent: initialComposerContent,
};

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

private get editorStateKey(): string {
let key = WYSIWYG_EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${this.props.relation.event_id}`;
}
return key;
}

private restoreWysiwygEditorState(): WysiwygComposerState | undefined {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const state: WysiwygComposerState = JSON.parse(json);
return state;
} catch (e) {
logger.error(e);
}
}
return undefined;
}

private saveWysiwygEditorState = (): void => {
if (this.shouldSaveWysiwygEditorState()) {
const { isRichTextEnabled, composerContent } = this.state;
const replyEventId = this.props.replyToEvent ? this.props.replyToEvent.getId() : undefined;
const item: WysiwygComposerState = {
content: composerContent,
isRichText: isRichTextEnabled,
replyEventId: replyEventId,
};
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else {
this.clearStoredEditorState();
}
};

// should save state when wysiwyg is enabled and has contents or reply is open
private shouldSaveWysiwygEditorState = (): boolean => {
const { isWysiwygLabEnabled, isComposerEmpty } = this.state;
return isWysiwygLabEnabled && (!isComposerEmpty || !!this.props.replyToEvent);
};

private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}

private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording;
}
Expand Down Expand Up @@ -265,6 +342,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);

window.removeEventListener("beforeunload", this.saveWysiwygEditorState);
this.saveWysiwygEditorState();
// clean up our listeners by setting our cached recording to falsy (see internal setter)
this.voiceRecording = null;
}
Expand Down
12 changes: 12 additions & 0 deletions test/components/structures/MatrixChat-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,18 @@ describe("<MatrixChat />", () => {
expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).toBeNull();
});

it("should clean up wysiwyg drafts", async () => {
Date.now = jest.fn(() => timestamp);
localStorage.setItem(`mx_wysiwyg_state_${roomId}`, "fake_content");
localStorage.setItem(`mx_wysiwyg_state_${unknownRoomId}`, "fake_content");
await getComponentAndWaitForReady();
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing);
// let things settle
await flushPromises();
expect(localStorage.getItem(`mx_wysiwyg_state_${roomId}`)).not.toBeNull();
expect(localStorage.getItem(`mx_wysiwyg_state_${unknownRoomId}`)).toBeNull();
});

it("should not clean up drafts before expiry", async () => {
// Set the last cleanup to the recent past
localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content");
Expand Down
Loading

0 comments on commit 70665d3

Please sign in to comment.