diff --git a/src/DraftCleaner.ts b/src/DraftCleaner.ts new file mode 100644 index 0000000000..5e6c1cbae7 --- /dev/null +++ b/src/DraftCleaner.ts @@ -0,0 +1,78 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { logger } from "matrix-js-sdk/src/logger"; + +import { MatrixClientPeg } from "./MatrixClientPeg"; +import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer"; + +// The key used to persist the the timestamp we last cleaned up drafts +export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup"; +// The period of time we wait between cleaning drafts +export const DRAFT_CLEANUP_PERIOD = 1000 * 60 * 60 * 24 * 30; + +/** + * Checks if `DRAFT_CLEANUP_PERIOD` has expired, if so, deletes any stord editor drafts that exist for rooms that are not in the known list. + */ +export function cleanUpDraftsIfRequired(): void { + if (!shouldCleanupDrafts()) { + return; + } + logger.debug(`Cleaning up editor drafts...`); + cleaupDrafts(); + try { + localStorage.setItem(DRAFT_LAST_CLEANUP_KEY, String(Date.now())); + } catch (error) { + logger.error("Failed to persist draft cleanup key", error); + } +} + +/** + * + * @returns {bool} True if the timestamp has not been persisted or the `DRAFT_CLEANUP_PERIOD` has expired. + */ +function shouldCleanupDrafts(): boolean { + try { + const lastCleanupTimestamp = localStorage.getItem(DRAFT_LAST_CLEANUP_KEY); + if (!lastCleanupTimestamp) { + return true; + } + const parsedTimestamp = Number.parseInt(lastCleanupTimestamp || "", 10); + if (!Number.isInteger(parsedTimestamp)) { + return true; + } + return Date.now() > parsedTimestamp + DRAFT_CLEANUP_PERIOD; + } catch (error) { + return true; + } +} + +/** + * Clear all drafts for the CIDER editor 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; + // 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}`); + localStorage.removeItem(keyName); + } + } +} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8335b0e3f6..53514ab817 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -143,6 +143,7 @@ import { checkSessionLockFree, getSessionLock } from "../../utils/SessionLock"; import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; import { LoginSplashView } from "./auth/LoginSplashView"; +import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; // legacy export export { default as Views } from "../../Views"; @@ -1528,6 +1529,9 @@ export default class MatrixChat extends React.PureComponent { } if (state === SyncState.Syncing && prevState === SyncState.Syncing) { + // We know we have performabed a live update and known rooms should be in a good state. + // Now is a good time to clean up drafts. + cleanUpDraftsIfRequired(); return; } logger.debug(`MatrixClient sync state => ${state}`); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index eee2a476a7..9e986a181e 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -71,6 +71,9 @@ import { IDiff } from "../../../editor/diff"; import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; +// The prefix used when persisting editor drafts to localstorage. +export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; + /** * Build the mentions information based on the editor model (and any related events): * @@ -604,7 +607,7 @@ export class SendMessageComposer extends React.Component ({ completeAuthorizationCodeGrant: jest.fn(), @@ -598,6 +599,41 @@ describe("", () => { expect(screen.getByText(`Welcome ${userId}`)).toBeInTheDocument(); }); + describe("clean up drafts", () => { + const roomId = "!room:server.org"; + const unknownRoomId = "!room2:server.org"; + const room = new Room(roomId, mockClient, userId); + const timestamp = 2345678901234; + beforeEach(() => { + localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); + localStorage.setItem(`mx_cider_state_${roomId}`, "fake_content"); + mockClient.getRoom.mockImplementation((id) => [room].find((room) => room.roomId === id) || null); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it("should clean up drafts", async () => { + Date.now = jest.fn(() => timestamp); + localStorage.setItem(`mx_cider_state_${roomId}`, "fake_content"); + localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); + await getComponentAndWaitForReady(); + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); + // let things settle + await flushPromises(); + expect(localStorage.getItem(`mx_cider_state_${roomId}`)).not.toBeNull(); + expect(localStorage.getItem(`mx_cider_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"); + localStorage.setItem(DRAFT_LAST_CLEANUP_KEY, String(timestamp - 100)); + await getComponentAndWaitForReady(); + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); + expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).not.toBeNull(); + }); + }); + describe("onAction()", () => { beforeEach(() => { jest.spyOn(defaultDispatcher, "dispatch").mockClear();