From 48ec3716ca1b75148487420bac4185954ee8ef4d Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Thu, 29 May 2025 16:19:20 +1000 Subject: [PATCH] feat: manage rooms locally --- excalidraw-app/collab/Collab.tsx | 20 +++ excalidraw-app/data/roomManager.ts | 193 +++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 excalidraw-app/data/roomManager.ts diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index bed130e24a..595295611f 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -83,6 +83,7 @@ import { saveUsernameToLocalStorage, } from "../data/localStorage"; import { resetBrowserStateVersions } from "../data/tabSync"; +import { roomManager } from "../data/roomManager"; import { collabErrorIndicatorAtom } from "./CollabError"; import Portal from "./Portal"; @@ -547,6 +548,25 @@ class Collab extends PureComponent { }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); + + // Save room data to local room manager for new rooms + try { + await roomManager.addRoom( + roomId, + roomKey, + getCollaborationLink({ roomId, roomKey }), + "", // User can edit this later + ); + } catch (error) { + console.warn("Failed to save room to local storage:", error); + } + } else { + // Update access time for existing rooms + try { + await roomManager.updateRoomAccess(existingRoomLinkData.roomId); + } catch (error) { + console.warn("Failed to update room access time:", error); + } } // fallback in case you're not alone in the room but still don't receive diff --git a/excalidraw-app/data/roomManager.ts b/excalidraw-app/data/roomManager.ts new file mode 100644 index 0000000000..008cad901b --- /dev/null +++ b/excalidraw-app/data/roomManager.ts @@ -0,0 +1,193 @@ +import { + generateEncryptionKey, + encryptData, + decryptData, +} from "@excalidraw/excalidraw/data/encryption"; + +export interface CollabRoom { + id: string; + roomId: string; + roomKey: string; + createdAt: number; + lastAccessed: number; + url: string; + name?: string; +} + +interface EncryptedRoomData { + rooms: CollabRoom[]; + version: number; +} + +const ROOM_STORAGE_KEY = "excalidraw-user-rooms"; +const ROOM_STORAGE_VERSION = 1; + +class RoomManager { + private userKey: string | null = null; + + private async getUserKey(): Promise { + if (this.userKey) { + return this.userKey; + } + + try { + const stored = localStorage.getItem(`${ROOM_STORAGE_KEY}-key`); + if (stored) { + this.userKey = stored; + return this.userKey; + } + } catch (error) { + console.warn("Failed to load user key from localStorage:", error); + } + + this.userKey = await generateEncryptionKey(); + + try { + localStorage.setItem(`${ROOM_STORAGE_KEY}-key`, this.userKey); + } catch (error) { + console.warn("Failed to save user key to localStorage:", error); + } + + return this.userKey; + } + + private async encryptRoomData( + data: EncryptedRoomData, + ): Promise<{ data: ArrayBuffer; iv: Uint8Array }> { + const userKey = await this.getUserKey(); + const jsonData = JSON.stringify(data); + const { encryptedBuffer, iv } = await encryptData(userKey, jsonData); + return { data: encryptedBuffer, iv }; + } + + private async decryptRoomData( + encryptedData: ArrayBuffer, + iv: Uint8Array, + ): Promise { + try { + const userKey = await this.getUserKey(); + const decryptedBuffer = await decryptData(iv, encryptedData, userKey); + const jsonString = new TextDecoder().decode(decryptedBuffer); + const data = JSON.parse(jsonString) as EncryptedRoomData; + + if (data.version === ROOM_STORAGE_VERSION && Array.isArray(data.rooms)) { + return data; + } + + return null; + } catch (error) { + console.warn("Failed to decrypt room data:", error); + return null; + } + } + + private async loadRooms(): Promise { + try { + const storedData = localStorage.getItem(ROOM_STORAGE_KEY); + if (!storedData) { + return []; + } + + const { data, iv } = JSON.parse(storedData); + const dataBuffer = new Uint8Array(data).buffer; + const ivArray = new Uint8Array(iv); + + const decryptedData = await this.decryptRoomData(dataBuffer, ivArray); + return decryptedData?.rooms || []; + } catch (error) { + console.warn("Failed to load rooms:", error); + return []; + } + } + + private async saveRooms(rooms: CollabRoom[]): Promise { + try { + const data: EncryptedRoomData = { + rooms, + version: ROOM_STORAGE_VERSION, + }; + + const { data: encryptedData, iv } = await this.encryptRoomData(data); + + const storageData = { + data: Array.from(new Uint8Array(encryptedData)), + iv: Array.from(iv), + }; + + localStorage.setItem(ROOM_STORAGE_KEY, JSON.stringify(storageData)); + } catch (error) { + console.warn("Failed to save rooms:", error); + } + } + + async addRoom( + roomId: string, + roomKey: string, + url: string, + name?: string, + ): Promise { + const rooms = await this.loadRooms(); + + const filteredRooms = rooms.filter((room) => room.roomId !== roomId); + + const newRoom: CollabRoom = { + id: crypto.randomUUID(), + roomId, + roomKey, + createdAt: Date.now(), + lastAccessed: Date.now(), + url, + name, + }; + + filteredRooms.unshift(newRoom); + + // Limit to the most recent 20 rooms + const limitedRooms = filteredRooms.slice(0, 20); + + await this.saveRooms(limitedRooms); + } + + async getRooms(): Promise { + const rooms = await this.loadRooms(); + return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed); + } + + async updateRoomAccess(roomId: string): Promise { + const rooms = await this.loadRooms(); + const room = rooms.find((r) => r.roomId === roomId); + + if (room) { + room.lastAccessed = Date.now(); + await this.saveRooms(rooms); + } + } + + async deleteRoom(roomId: string): Promise { + const rooms = await this.loadRooms(); + const filteredRooms = rooms.filter((room) => room.roomId !== roomId); + await this.saveRooms(filteredRooms); + } + + async updateRoomName(roomId: string, name: string): Promise { + const rooms = await this.loadRooms(); + const room = rooms.find((r) => r.roomId === roomId); + + if (room) { + room.name = name; + await this.saveRooms(rooms); + } + } + + async clearAllRooms(): Promise { + try { + localStorage.removeItem(ROOM_STORAGE_KEY); + localStorage.removeItem(`${ROOM_STORAGE_KEY}-key`); + this.userKey = null; + } catch (error) { + console.warn("Failed to clear rooms:", error); + } + } +} + +export const roomManager = new RoomManager();