Compare commits

...

8 Commits

Author SHA1 Message Date
Ryan Di
baf68fe663 only show deletion msg to owner 2025-06-02 14:54:37 +10:00
Ryan Di
08a39e2034 integrate deletion to collab sessions 2025-06-02 14:50:39 +10:00
Ryan Di
f71c200106 add room owner check to room manager 2025-06-02 13:38:39 +10:00
Ryan Di
ed63af1ad8 add delete session btn to active room dialog 2025-06-02 13:07:45 +10:00
Ryan Di
ca5c34ac48 store all rooms 2025-06-02 13:07:14 +10:00
Ryan Di
97cc331530 remove room list 2025-06-02 13:06:48 +10:00
Ryan Di
23175654b8 feat: room list 2025-05-29 21:33:22 +10:00
Ryan Di
48ec3716ca feat: manage rooms locally 2025-05-29 16:19:20 +10:00
9 changed files with 386 additions and 6 deletions

View File

@ -23,6 +23,7 @@ export enum WS_SUBTYPES {
INVALID_RESPONSE = "INVALID_RESPONSE",
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
DELETE = "SCENE_DELETE",
MOUSE_LOCATION = "MOUSE_LOCATION",
IDLE_STATUS = "IDLE_STATUS",
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",

View File

@ -72,6 +72,7 @@ import {
} from "../data/FileManager";
import { LocalData } from "../data/LocalData";
import {
deleteRoomFromFirebase,
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
@ -83,6 +84,7 @@ import {
saveUsernameToLocalStorage,
} from "../data/localStorage";
import { resetBrowserStateVersions } from "../data/tabSync";
import { roomManager } from "../data/roomManager";
import { collabErrorIndicatorAtom } from "./CollabError";
import Portal from "./Portal";
@ -114,6 +116,7 @@ export interface CollabAPI {
onPointerUpdate: CollabInstance["onPointerUpdate"];
startCollaboration: CollabInstance["startCollaboration"];
stopCollaboration: CollabInstance["stopCollaboration"];
deleteRoom: CollabInstance["deleteRoom"];
syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: CollabInstance["setUsername"];
@ -227,6 +230,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate,
startCollaboration: this.startCollaboration,
deleteRoom: this.deleteRoom,
syncElements: this.syncElements,
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
stopCollaboration: this.stopCollaboration,
@ -547,6 +551,25 @@ class Collab extends PureComponent<CollabProps, CollabState> {
});
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
@ -655,6 +678,18 @@ class Collab extends PureComponent<CollabProps, CollabState> {
break;
}
case WS_SUBTYPES.DELETE: {
const { roomId } = decryptedData.payload;
if (this.portal.roomId === roomId) {
this.destroySocketClient({ isUnload: true });
this.setIsCollaborating(false);
this.setActiveRoomLink(null);
this.setErrorDialog(t("alerts.collabRoomDeleted"));
window.history.pushState({}, APP_NAME, window.location.origin);
}
break;
}
default: {
assertNever(decryptedData, null);
}
@ -874,6 +909,42 @@ class Collab extends PureComponent<CollabProps, CollabState> {
});
};
deleteRoom = async (): Promise<void> => {
if (!this.portal.socket || !this.portal.roomId) {
return;
}
const { roomId, roomKey } = this.portal;
if (!roomId || !roomKey) {
return;
}
const link = this.getActiveRoomLink();
if (!link) {
return;
}
// check if the room belongs to the current user
const isOwner = await roomManager.isRoomOwnedByUser(link);
if (!isOwner) {
return;
}
try {
this.portal.broadcastRoomDeletion();
await deleteRoomFromFirebase(roomId, roomKey);
await roomManager.deleteRoom(roomId);
this.stopCollaboration(false);
this.setActiveRoomLink(null);
window.history.pushState({}, APP_NAME, window.location.origin);
} catch (error) {
console.error("Failed to delete room:", error);
this.setErrorDialog(t("errors.roomDeletionFailed"));
throw error;
}
};
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
this.lastBroadcastedOrReceivedSceneVersion = version;
};

View File

@ -252,6 +252,20 @@ class Portal {
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
}
};
broadcastRoomDeletion = async () => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["ROOM_DELETED"] = {
type: WS_SUBTYPES.DELETE,
payload: {
socketId: this.socket.id as SocketId,
roomId: this.roomId!,
},
};
this._broadcastSocketData(data as SocketUpdateData);
}
};
}
export default Portal;

View File

@ -315,3 +315,10 @@ export const loadFilesFromFirebase = async (
return { loadedFiles, erroredFiles };
};
export const deleteRoomFromFirebase = async (
roomId: string,
roomKey: string,
): Promise<void> => {
// TODO: delete the room...
};

View File

@ -119,6 +119,13 @@ export type SocketUpdateDataSource = {
username: string;
};
};
ROOM_DELETED: {
type: WS_SUBTYPES.DELETE;
payload: {
socketId: SocketId;
roomId: string;
};
};
};
export type SocketUpdateDataIncoming =
@ -310,7 +317,7 @@ export const exportToBackend = async (
const response = await fetch(BACKEND_V2_POST, {
method: "POST",
body: payload.buffer,
body: new Uint8Array(payload.buffer),
});
const json = await response.json();
if (json.id) {

View File

@ -0,0 +1,218 @@
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<string> {
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<EncryptedRoomData | null> {
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<CollabRoom[]> {
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<void> {
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<void> {
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);
await this.saveRooms(filteredRooms);
}
async getRooms(): Promise<CollabRoom[]> {
const rooms = await this.loadRooms();
return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed);
}
async updateRoomAccess(roomId: string): Promise<void> {
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<void> {
const rooms = await this.loadRooms();
const filteredRooms = rooms.filter((room) => room.roomId !== roomId);
await this.saveRooms(filteredRooms);
}
async updateRoomName(roomId: string, name: string): Promise<void> {
const rooms = await this.loadRooms();
const room = rooms.find((r) => r.roomId === roomId);
if (room) {
room.name = name;
await this.saveRooms(rooms);
}
}
async isRoomOwnedByUser(url: string): Promise<boolean> {
try {
const rooms = await this.loadRooms();
const _url = new URL(url);
const match = _url.hash.match(/room=([^,]+),([^&]+)/);
if (!match) {
return false;
}
const roomId = match[1];
return rooms.some((room) => room.roomId === roomId);
} catch (error) {
console.warn("Failed to check room ownership:", error);
return false;
}
}
async getCurrentRoom(): Promise<CollabRoom | null> {
const rooms = await this.loadRooms();
if (rooms.length === 0) {
return null;
}
// Return the most recently accessed room
return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed)[0];
}
async clearAllRooms(): Promise<void> {
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();

View File

@ -106,6 +106,10 @@
color: var(--text-primary-color);
&__text {
margin-bottom: 1rem;
}
& strong {
display: block;
font-weight: 700;
@ -155,11 +159,16 @@
& p + p {
margin-top: 1em;
}
& h3 {
font-weight: 600;
}
}
&__actions {
display: flex;
justify-content: center;
gap: 0.75rem;
}
}
}

View File

@ -11,6 +11,7 @@ import {
share,
shareIOS,
shareWindows,
TrashIcon,
} from "@excalidraw/excalidraw/components/icons";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
@ -18,6 +19,8 @@ import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS, getFrame } from "@excalidraw/common";
import { useEffect, useRef, useState } from "react";
import { roomManager } from "excalidraw-app/data/roomManager";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import { activeRoomLinkAtom } from "../collab/Collab";
@ -69,6 +72,19 @@ const ActiveRoomDialog = ({
const isShareSupported = "share" in navigator;
const { onCopy, copyStatus } = useCopyStatus();
const [isRoomOwner, setIsRoomOwner] = useState(false);
useEffect(() => {
roomManager
.isRoomOwnedByUser(activeRoomLink)
.then((isOwned) => {
setIsRoomOwner(isOwned);
})
.catch((error) => {
console.warn("Failed to check room ownership:", error);
setIsRoomOwner(false);
});
}, [activeRoomLink]);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
@ -153,7 +169,10 @@ const ActiveRoomDialog = ({
</span>
{t("roomDialog.desc_privacy")}
</p>
<h3>Stop Session</h3>
<p>{t("roomDialog.desc_exitSession")}</p>
{isRoomOwner && <h3>Delete Session</h3>}
{isRoomOwner && <p>{t("roomDialog.desc_deleteSession")}</p>}
</div>
<div className="ShareDialog__active__actions">
@ -171,6 +190,21 @@ const ActiveRoomDialog = ({
}
}}
/>
{isRoomOwner && (
<FilledButton
size="large"
label={t("roomDialog.button_deleteSession")}
icon={TrashIcon}
color="danger"
onClick={() => {
trackEvent("share", "room deleted");
collabAPI.deleteRoom();
if (!collabAPI.isCollaborating()) {
handleClose();
}
}}
/>
)}
</div>
</>
);
@ -180,7 +214,6 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
const { t } = useI18n();
const { collabAPI } = props;
const startCollabJSX = collabAPI ? (
<>
<div className="ShareDialog__picker__header">
@ -188,8 +221,15 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
</div>
<div className="ShareDialog__picker__description">
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
{t("roomDialog.desc_privacy")}
<div className="ShareDialog__picker__description__text">
{t("roomDialog.desc_intro")}
</div>
<div className="ShareDialog__picker__description__text">
{t("roomDialog.desc_privacy")}
</div>
<div className="ShareDialog__picker__description__text">
{t("roomDialog.desc_warning")}
</div>
</div>
<div className="ShareDialog__picker__button">
@ -204,6 +244,14 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
/>
</div>
<div
style={{
height: "1px",
backgroundColor: "var(--color-border)",
width: "100%",
}}
></div>
{props.type === "share" && (
<div className="ShareDialog__separator">
<span>{t("shareDialog.or")}</span>

View File

@ -253,7 +253,8 @@
"resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!",
"collabRoomDeleted": "This collab room has been deleted by its owner."
},
"errors": {
"unsupportedFileType": "Unsupported file type.",
@ -280,7 +281,8 @@
},
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
"asyncPasteFailedOnParse": "Couldn't paste.",
"copyToSystemClipboardFailed": "Couldn't copy to clipboard."
"copyToSystemClipboardFailed": "Couldn't copy to clipboard.",
"roomDeletionFailed": "Couldn't delete the collaboration room."
},
"toolBar": {
"selection": "Selection",
@ -376,11 +378,14 @@
"roomDialog": {
"desc_intro": "Invite people to collaborate on your drawing.",
"desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.",
"desc_warning": "Starting a new session will automatically delete your last active session. Please make sure to save your work from the last session before starting a new one.",
"button_startSession": "Start session",
"button_stopSession": "Stop session",
"button_deleteSession": "Delete session",
"desc_inProgressIntro": "Live-collaboration session is now in progress.",
"desc_shareLink": "Share this link with anyone you want to collaborate with:",
"desc_exitSession": "Stopping the session will disconnect you from the room, but you'll be able to continue working with the scene, locally. Note that this won't affect other people, and they'll still be able to collaborate on their version.",
"desc_deleteSession": "You're the creator of this session, so you can delete it if you wish to stop collaborating with others. Deleting a session is permanent and will make the scene inaccessible to everyone (including you). Please be sure to save anything important before deleting.",
"shareTitle": "Join a live collaboration session on Excalidraw"
},
"errorDialog": {