diff --git a/src/App.tsx b/src/App.tsx index 55b6b0d..2e5e72f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import type { ResolvablePromise } from '@excalidraw/excalidraw/types/utils' import type { NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js' import { useExcalidrawLang } from './hooks/useExcalidrawLang' +import SaveStatus from './SaveStatus' interface WhiteboardAppProps { fileId: number @@ -46,6 +47,7 @@ export default function App({ const fileNameWithoutExtension = fileName.split('.').slice(0, -1).join('.') const [viewModeEnabled, setViewModeEnabled] = useState(isEmbedded) + const [roomDataSaved, setRoomDataSaved] = useState(true) const [zenModeEnabled] = useState(isEmbedded) const [gridModeEnabled] = useState(false) @@ -91,10 +93,17 @@ export default function App({ const [excalidrawAPI, setExcalidrawAPI] = useState(null) const [collab, setCollab] = useState(null) + const [collabStarted, setCollabStarted] = useState(false) - if (excalidrawAPI && !collab) { setCollab(new Collab(excalidrawAPI, fileId, publicSharingToken, setViewModeEnabled)) } - if (collab && !collab.portal.socket) collab.startCollab() + if (excalidrawAPI && !collab) { setCollab(new Collab(excalidrawAPI, fileId, publicSharingToken, setViewModeEnabled, setRoomDataSaved)) } + if (collab && !collabStarted) { + setCollabStarted(true) + collab.startCollab() + } + + const [isSmartPickerInserted, setIsSmartPickerInserted] = useState(false) useEffect(() => { + if (isSmartPickerInserted) return const extraTools = document.getElementsByClassName( 'App-toolbar__extra-tools-trigger', )[0] @@ -107,6 +116,7 @@ export default function App({ ) const root = createRoot(smartPick) root.render(renderSmartPicker()) + setIsSmartPickerInserted(true) } }) @@ -236,12 +246,26 @@ export default function App({ ) } + const renderTopRightUI = () => { + if (collab?.portal.socket) { + return ( + { + collab?.portal.requestStoreToServer() + } + } + /> + ) + } + } + return (
true} renderEmbeddable={Embeddable} + renderTopRightUI={renderTopRightUI} excalidrawAPI={(api: ExcalidrawImperativeAPI) => { console.log(api) console.log('Setting API') diff --git a/src/SaveStatus.tsx b/src/SaveStatus.tsx new file mode 100644 index 0000000..545cc12 --- /dev/null +++ b/src/SaveStatus.tsx @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import SaveStatus from './SaveStatus.vue' +import VueWrapper from './VueWrapper' + +export default function(props:{saving: Boolean}) { + return React.createElement(VueWrapper, { componentProps: props, component: SaveStatus }) +} diff --git a/src/SaveStatus.vue b/src/SaveStatus.vue new file mode 100644 index 0000000..137e55e --- /dev/null +++ b/src/SaveStatus.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index fcbcc2a..f4977f4 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -114,6 +114,12 @@ export class Portal { this.socket.on('client-broadcast', (data) => this.handleClientBroadcast(data), ) + + this.socket.on('room-data-saved', () => this.handleRoomDataSaved()) + } + + async handleRoomDataSaved() { + this.collab.setRoomDataSaved(true) } async handleReadOnlySocket() { @@ -240,6 +246,10 @@ export class Portal { await this._broadcastSocketData(data, true) } + async requestStoreToServer() { + this.socket?.emit('store-to-server', this.roomId) + } + async sendImageFiles(files: BinaryFiles) { Object.values(files).forEach(file => { this.collab.addFile(file) diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts index 8e1a471..063d4e0 100644 --- a/src/collaboration/collab.ts +++ b/src/collaboration/collab.ts @@ -17,15 +17,17 @@ export class Collab { portal: Portal publicSharingToken: string | null setViewModeEnabled: React.Dispatch> + setRoomDataSaved: React.Dispatch> lastBroadcastedOrReceivedSceneVersion: number = -1 private collaborators = new Map() private files = new Map() - constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null, setViewModeEnabled: React.Dispatch>) { + constructor(excalidrawAPI: ExcalidrawImperativeAPI, fileId: number, publicSharingToken: string | null, setViewModeEnabled: React.Dispatch>, setRoomDataSaved: React.Dispatch>) { this.excalidrawAPI = excalidrawAPI this.fileId = fileId this.publicSharingToken = publicSharingToken this.setViewModeEnabled = setViewModeEnabled + this.setRoomDataSaved = setRoomDataSaved this.portal = new Portal(`${fileId}`, this, publicSharingToken) } @@ -55,6 +57,7 @@ export class Collab { elements, }, ) + this.setRoomDataSaved(false) } private getLastBroadcastedOrReceivedSceneVersion = () => { @@ -69,6 +72,7 @@ export class Collab { this.lastBroadcastedOrReceivedSceneVersion = hashElementsVersion(elements) throttle(() => { this.portal.broadcastScene('SCENE_INIT', elements) + this.setRoomDataSaved(false) const syncedFiles = Array.from(this.files.keys()) const newFiles = Object.keys(files).filter((id) => !syncedFiles.includes(id)).reduce((acc, id) => { diff --git a/websocket_server/AppManager.js b/websocket_server/AppManager.js index e9e95f3..9fc681e 100644 --- a/websocket_server/AppManager.js +++ b/websocket_server/AppManager.js @@ -6,11 +6,13 @@ import dotenv from 'dotenv' import express from 'express' import PrometheusDataManager from './PrometheusDataManager.js' +import StorageManager from './StorageManager.js' dotenv.config() export default class AppManager { + /** @param {StorageManager} storageManager*/ constructor(storageManager) { this.app = express() this.storageManager = storageManager diff --git a/websocket_server/SocketManager.js b/websocket_server/SocketManager.js index de45e32..2a09a05 100644 --- a/websocket_server/SocketManager.js +++ b/websocket_server/SocketManager.js @@ -5,18 +5,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Server as SocketIO } from 'socket.io' +import { Socket, Server as SocketIO } from 'socket.io' import prometheusMetrics from 'socket.io-prometheus' import jwt from 'jsonwebtoken' import dotenv from 'dotenv' import Utils from './Utils.js' import { createAdapter } from '@socket.io/redis-streams-adapter' import SocketDataManager from './SocketDataManager.js' +import StorageManager from './StorageManager.js' dotenv.config() export default class SocketManager { + /** @param {StorageManager} storageManager */ constructor(server, roomDataManager, storageManager) { this.roomDataManager = roomDataManager this.storageManager = storageManager @@ -92,7 +94,7 @@ export default class SocketManager { }, ) next(new Error('Connection verified')) - } catch (e) {} + } catch (e) { } next(new Error('Authentication error')) } @@ -107,6 +109,7 @@ export default class SocketManager { socket.on('server-volatile-broadcast', (roomID, encryptedData) => this.serverVolatileBroadcastHandler(socket, roomID, encryptedData), ) + socket.on('store-to-server', (roomID) => this.storeToServerHandler(roomID, socket)) socket.on('image-add', (roomID, id, data) => this.imageAddHandler(socket, roomID, id, data)) socket.on('image-remove', (roomID, id, data) => this.imageRemoveHandler(socket, roomID, id, data)) socket.on('image-get', (roomID, id, data) => this.imageGetHandler(socket, roomID, id, data)) @@ -117,6 +120,17 @@ export default class SocketManager { socket.on('disconnect', () => this.handleDisconnect(socket)) } + /** + * @param {int} roomID + * @param {Socket} socket + */ + async storeToServerHandler(roomID, socket) { + this.storageManager.saveRoomDataToServer(roomID).then(() => { + socket.emit("room-data-saved", roomID) + socket.broadcast.to(roomID).emit("room-data-saved", roomID) + }) + } + async handleDisconnect(socket) { await this.socketDataManager.deleteSocketData(socket.id) socket.removeAllListeners() @@ -213,7 +227,9 @@ export default class SocketManager { socketData.user.id, ) } - + /** + * @param {Socket} socket + */ async serverVolatileBroadcastHandler(socket, roomID, encryptedData) { const payload = JSON.parse( Utils.convertArrayBufferToString(encryptedData), diff --git a/websocket_server/StorageManager.js b/websocket_server/StorageManager.js index e58255c..97957e0 100644 --- a/websocket_server/StorageManager.js +++ b/websocket_server/StorageManager.js @@ -8,11 +8,16 @@ import StorageStrategy from './StorageStrategy.js' import LRUCacheStrategy from './LRUCacheStrategy.js' import RedisStrategy from './RedisStrategy.js' +import ApiService from './ApiService.js'; +import Room from './Room.js' export default class StorageManager { - - constructor(strategy) { + /** @param {StorageStrategy} strategy + * @param {ApiService} apiService + */ + constructor(strategy, apiService) { this.setStrategy(strategy) + this.apiService = apiService } setStrategy(strategy) { @@ -38,6 +43,11 @@ export default class StorageManager { async clear() { await this.strategy.clear() } + /** @param { number }roomId */ + async saveRoomDataToServer(roomId) { + const room = await this.get(roomId) + this.apiService.saveRoomDataToServer(roomId, room.data, room.lastEditedUser, room.files); + } getRooms() { return this.strategy.getRooms() @@ -58,7 +68,7 @@ export default class StorageManager { throw new Error('Invalid storage strategy type') } - return new StorageManager(strategy) + return new StorageManager(strategy,apiService) } }