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

RTE drafts #12674

Merged
merged 21 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4e0ad16
Add drafts to the RTE and tests
langleyd Jun 24, 2024
4b367dc
Merge branch 'develop' into langleyd/rte_drafts
langleyd Jun 24, 2024
2c92308
test drafts in threads
langleyd Jun 24, 2024
c9e8fc5
Merge branch 'langleyd/rte_drafts' of https://github.com/matrix-org/m…
langleyd Jun 24, 2024
ba9523d
lint
langleyd Jun 24, 2024
7b5eb7a
Merge branch 'develop' into langleyd/rte_drafts
langleyd Jun 24, 2024
57b51a0
Add unit test.
langleyd Jun 24, 2024
c084c86
Merge branch 'langleyd/rte_drafts' of https://github.com/matrix-org/m…
langleyd Jun 24, 2024
cd973b2
Fix test failure
florianduros Jun 25, 2024
e385502
Merge branch 'develop' into langleyd/rte_drafts
langleyd Jun 25, 2024
2741659
Remove unused import
langleyd Jun 25, 2024
880de62
Merge branch 'develop' of https://github.com/matrix-org/matrix-react-…
langleyd Aug 7, 2024
482268e
Clean up wysiwyg drafts and add test.
langleyd Aug 7, 2024
d5bbaa1
Merge branch 'develop' into langleyd/rte_drafts
langleyd Aug 8, 2024
06da902
Merge branch 'develop' of https://github.com/matrix-org/matrix-react-…
langleyd Aug 8, 2024
6dab422
Merge branch 'langleyd/rte_drafts' of https://github.com/matrix-org/m…
langleyd Aug 8, 2024
53e0523
Fix typo
florianduros Aug 19, 2024
7489ecf
Merge branch 'langleyd/rte_drafts' of https://github.com/matrix-org/m…
langleyd Aug 21, 2024
c96b9e2
Merge branch 'develop' of https://github.com/matrix-org/matrix-react-…
langleyd Aug 21, 2024
97aa83a
Merge branch 'develop' into langleyd/rte_drafts
langleyd Aug 21, 2024
48c6a99
Add timeout to allow for wasm loading.
langleyd Aug 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading