import _ from "lodash"; import { action, IObservableArray, IObservableValue, observable, ObservableMap, remove, set, } from "mobx"; import { Node } from "slate"; import { CommentThreadItem, ID, Mouse, MouseClick, Slide, SlideBoardItemStatus, SlideObjectType, } from "../../shared/types"; import { socket } from "../../shared/utils/socket"; export enum SocketActions { ENTER_ROOM = "ENTER_ROOM", LEAVE_ROOM = "LEAVE_ROOM", MOUSE_MOVEMENT = "MOUSE_MOVEMENT", CREATE_SLIDE = "CREATE_SLIDE", DELETE_SLIDES = "DELETE_SLIDES", MOUSE_CLICK = "MOUSE_CLICK", CREATE_SLIDE_OBJECT = "CREATE_SLIDE_OBJECT", UPDATE_SLIDE_OBJECT = "UPDATE_SLIDE_OBJECT", DELETE_SLIDE_OBJECT = "DELETE_SLIDE_OBJECT", SET_SELECTED_SLIDE_ELEMENT = "SET_SELECTED_SLIDE_ELEMENT", } export enum RemoteSocketActions { ENTER_ROOM = "REMOTE_ENTER_ROOM", LEAVE_ROOM = "REMOTE_LEAVE_ROOM", MOUSE_MOVEMENT = "REMOTE_MOUSE_MOVEMENT", CREATE_SLIDE = "REMOTE_CREATE_SLIDE", DELETE_SLIDES = "REMOTE_DELETE_SLIDES", MOUSE_CLICK = "REMOTE_MOUSE_CLICK", CREATE_SLIDE_OBJECT = "REMOTE_CREATE_SLIDE_OBJECT", UPDATE_SLIDE_OBJECT = "REMOTE_UPDATE_SLIDE_OBJECT", DELETE_SLIDE_OBJECT = "REMOTE_DELETE_SLIDE_OBJECT", SET_SELECTED_SLIDE_ELEMENT = "SET_SELECTED_SLIDE_ELEMENT", } const UPDATE = "UPDATE"; const REMOTE_UPDATE = "REMOTE_UPDATE"; export type RemotePayload = { action: RemoteSocketActions; data: any; }; export type MouseMovementData = { x: number; y: number; userId: ID; }; export type EnterRoomData = { userId: ID; }; export type LeaveRoomData = { userId: ID; }; export type CreateSlideData = { id: ID; index: number; content: string; }; export type DeleteSlidesData = { slideIds: ID[]; }; export type MouseClickData = { userId: ID; x: number; y: number; timestamp: number; }; // With these crud operations, we will absolutely run into conflicts. // We're not going to handle them for now export type CreateSlideObjectData = | CreateSlideTextData | CreateSlideAirtableData | CreateSlideCommentData | CreateSlideRectangleData | CreateSlideBoardData | CreateSlideIdeaData | CreateSlideConnectionData; export type CreateSlideConnectionData = { type: SlideObjectType.CONNECTION; startId: ID; startOrientation: string; endId: ID; endOrientation: string; } & CreateSlideObjectDataBase; export type CreateSlideIdeaData = { type: SlideObjectType.IDEA; boardId: ID | null; status: SlideBoardItemStatus; title: string; description: Node[]; // JSON blob for a text editor index: number; width: number; height: number; } & CreateSlideObjectDataBase; export type CreateSlideBoardData = { type: SlideObjectType.BOARD; width: number; height: number; } & CreateSlideObjectDataBase; export type CreateSlideRectangleData = { type: SlideObjectType.RECTANGLE; width: number; height: number; } & CreateSlideObjectDataBase; export type CreateSlideCommentData = { type: SlideObjectType.COMMENT; thread: CommentThreadItem[]; } & CreateSlideObjectDataBase; export type CreateSlideAirtableData = { type: SlideObjectType.AIRTABLE; } & CreateSlideObjectDataBase; export type CreateSlideTextData = { text: string; fontSize: number; type: SlideObjectType.TEXT; width: number | null; height: number | null; } & CreateSlideObjectDataBase; export type CreateSlideObjectDataBase = { id: ID; slideId: ID; type: SlideObjectType; x: number; y: number; }; export type UpdateSlideObjectData = { id: ID; slideId: ID; data: any; }; export type DeleteSlideObjectData = { id: ID; slideId: ID; }; export type SetSelectedSlideElementData = { id: ID | null; userId: ID; }; export type SlideshowState = { subscribers: { [userId: string]: ID }; mouseInfo: { [userId: string]: Mouse }; slides: { [slideId: string]: Slide }; subscriberSelectedElements: { [userId: string]: ID | null }; }; export type StoreProps = { store: SlideshowStore; }; export class SlideshowStore { userId: IObservableValue; roomId: IObservableValue; subscribers: ObservableMap; socket: SocketIOClient.Socket; mouseInfo: ObservableMap; slides: ObservableMap; mouseClicks: ObservableMap; currentSlide: IObservableValue; selectedSlideElement: IObservableValue; isPresenting: IObservableValue; selectedSlides: IObservableArray; createItemSelector: IObservableValue; currentDragElement: IObservableValue; currentResizeElement: IObservableValue; currentResizeOrientation: IObservableValue; currentConnectingElement: IObservableValue; currentConnectingOrientation: IObservableValue; currentConnectingCoordinates: ObservableMap; isCanvasDragging: IObservableValue; isSelectDragging: IObservableValue; subscriberSelectedElements: ObservableMap; showBoardModal: IObservableValue; showSettingsModal: IObservableValue; showChangelogModal: IObservableValue; constructor( userId: ID, slideshowId: ID, initialSlideshowState: SlideshowState ) { const firstSlide = initialSlideshowState.slides && !_.isEmpty(initialSlideshowState.slides) ? Object.keys(initialSlideshowState.slides).reduce((a, b) => Math.min( initialSlideshowState.slides[a].index, initialSlideshowState.slides[b].index ) === initialSlideshowState.slides[a].index ? a : b ) : null; this.userId = observable.box(userId); this.roomId = observable.box(slideshowId); this.socket = socket; // Shared state this.subscribers = observable.map( initialSlideshowState.subscribers ); this.mouseInfo = observable.map(initialSlideshowState.mouseInfo); this.mouseClicks = observable.map({}); this.slides = observable.map(initialSlideshowState.slides); // Local state // TODO: Show current slide across different people this.currentSlide = observable.box(firstSlide); this.selectedSlideElement = observable.box(null); this.isPresenting = observable.box(false); this.selectedSlides = observable.array([]); this.createItemSelector = observable.box(null); this.currentDragElement = observable.box(null); this.currentResizeElement = observable.box(null); this.currentResizeOrientation = observable.box(null); this.currentConnectingElement = observable.box(null); this.currentConnectingOrientation = observable.box(null); this.currentConnectingCoordinates = observable.map({}); this.isCanvasDragging = observable.box(false); this.isSelectDragging = observable.box(false); this.subscriberSelectedElements = observable.map( initialSlideshowState.subscriberSelectedElements ); this.showBoardModal = observable.box(null); this.showSettingsModal = observable.box(false); this.showChangelogModal = observable.box(false); this.socket.on( `${REMOTE_UPDATE}:${this.roomId}`, (payload: RemotePayload) => { this.handleRemoteAction(payload.action, payload.data); } ); } emit(action: SocketActions, data: any) { this.handleAction(action, data); this.socket.emit(UPDATE, { roomId: this.roomId, action: action, data: data, }); } handleAction(action: SocketActions, data: any) { switch (action) { case SocketActions.ENTER_ROOM: this.handleEnterRoom(data); break; case SocketActions.LEAVE_ROOM: this.handleLeaveRoom(data); break; case SocketActions.MOUSE_MOVEMENT: this.handleMouseMovement(data); break; case SocketActions.CREATE_SLIDE: this.handleCreateSlide(data); break; case SocketActions.DELETE_SLIDES: this.handleDeleteSlides(data); break; case SocketActions.MOUSE_CLICK: this.handleMouseClick(data); break; case SocketActions.CREATE_SLIDE_OBJECT: this.handleCreateSlideObject(data); break; case SocketActions.UPDATE_SLIDE_OBJECT: this.handleUpdateSlideObject(data); break; case SocketActions.DELETE_SLIDE_OBJECT: this.handleDeleteSlideObject(data); break; case SocketActions.SET_SELECTED_SLIDE_ELEMENT: this.setSelectedSlideElement(data); break; default: this.handleOtherAction(action, data); } } @action handleEnterRoom(data: EnterRoomData) { set(this.subscribers, data.userId, data.userId); } @action handleLeaveRoom(data: LeaveRoomData) { remove(this.slides, data.userId); } @action handleMouseMovement(data: MouseMovementData) { set(this.mouseInfo, data.userId, { x: data.x, y: data.y, userId: data.userId, }); } @action handleCreateSlide(data: CreateSlideData) { set(this.slides, data.id, { id: data.id, index: data.index, content: {}, }); } @action handleDeleteSlides(data: DeleteSlidesData) { data.slideIds.forEach((id) => { this.slides.delete(id); }); } handleCreateSlideObject(data: CreateSlideObjectData) { console.log("DATA"); console.log(data); switch (data.type) { case SlideObjectType.TEXT: this.handleCreateText(data as CreateSlideTextData); break; case SlideObjectType.AIRTABLE: this.handleCreateAirtable(data as CreateSlideAirtableData); break; case SlideObjectType.COMMENT: this.handleCreateComment(data as CreateSlideCommentData); break; case SlideObjectType.RECTANGLE: this.handleCreateRectangle(data as CreateSlideRectangleData); break; case SlideObjectType.BOARD: this.handleCreateBoard(data as CreateSlideBoardData); break; case SlideObjectType.IDEA: this.handleCreateIdea(data as CreateSlideIdeaData); break; case SlideObjectType.CONNECTION: this.handleCreateConnection(data as CreateSlideConnectionData); break; default: console.log("UNHANDLED OBJECT TYPE"); break; } } @action handleCreateConnection(data: CreateSlideConnectionData) { const { slideId, ...rest } = data; const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], ...rest, }, }, }); } @action handleCreateIdea(data: CreateSlideIdeaData) { console.log("CREATE SLIDE IDEA"); const { slideId, ...rest } = data; const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], ...rest, }, }, }); } @action handleCreateBoard(data: CreateSlideBoardData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], id: data.id, type: data.type, x: data.x, y: data.y, height: data.height, width: data.width, }, }, }); } @action handleCreateRectangle(data: CreateSlideRectangleData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], id: data.id, type: data.type, x: data.x, y: data.y, height: data.height, width: data.width, }, }, }); } @action handleCreateComment(data: CreateSlideCommentData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], id: data.id, type: data.type, x: data.x, y: data.y, thread: [], }, }, }); } @action handleCreateAirtable(data: CreateSlideAirtableData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], id: data.id, type: data.type, x: data.x, y: data.y, }, }, }); } @action handleCreateText(data: CreateSlideTextData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], id: data.id, type: data.type, text: data.text, x: data.x, y: data.y, }, }, }); } @action handleUpdateSlideObject(data: UpdateSlideObjectData) { const currentSlide = this.slides.get(data.slideId)!; this.slides.set(data.slideId, { ...currentSlide, content: { ...currentSlide.content, [data.id]: { ...currentSlide.content[data.id], ...data.data, }, }, }); } @action handleDeleteSlideObject(data: DeleteSlideObjectData) { const currentSlide = this.slides.get(data.slideId)!; const { [data.id]: omit, ...contentWithoutSlideObject } = currentSlide.content; this.slides.set(data.slideId, { ...currentSlide, content: contentWithoutSlideObject, }); this.setSelectedSlideElement(null); } handleOtherAction(action: SocketActions | RemoteSocketActions, data: any) { console.log("UNHANDLED ACTION: " + action); } @action handleSetSelectedSlideElement(data: SetSelectedSlideElementData) { const selectedElement = this.getSelectedElementForUser(data.userId); console.log("HANDLE SETTING"); console.log(selectedElement); if (selectedElement && selectedElement !== data.id) { console.log("DELETING"); this.subscriberSelectedElements.delete(selectedElement); } if (data.id) { this.subscriberSelectedElements.set(data.id, data.userId); } } /* * TODO: FIXME: This doesn't work the same as the others in that you call * the function and the function calls emit, not the other way around. * Consider cleaning this up later. */ @action setSelectedSlideElement(slideElementId: ID | null) { const selectedElement = this.getSelectedElementForUser(this.userId.get()); if (selectedElement && selectedElement !== slideElementId) { this.subscriberSelectedElements.delete(selectedElement); } if (slideElementId) { this.subscriberSelectedElements.set(slideElementId, this.userId.get()); } this.selectedSlideElement.set(slideElementId); this.socket.emit(UPDATE, { roomId: this.roomId, action: SocketActions.SET_SELECTED_SLIDE_ELEMENT, data: { id: slideElementId, userId: this.userId.get(), }, }); } @action setIsPresenting(isPresenting: boolean) { this.isPresenting.set(isPresenting); } @action setSelectedSlides(slideIds: ID[]) { this.selectedSlides.replace(slideIds); } @action nextSlide() { const sortedSlides = Array.from(this.slides.keys()).sort( (a, b) => this.slides.get(a)!.index - this.slides.get(b)!.index ); const currentIndex = sortedSlides.indexOf(this.currentSlide.get()!); if (currentIndex === sortedSlides.length - 1) { return; } this.currentSlide.set(sortedSlides[currentIndex + 1]); } @action previousSlide() { const sortedSlides = Array.from(this.slides.keys()).sort( (a, b) => this.slides.get(a)!.index - this.slides.get(b)!.index ); const currentIndex = sortedSlides.indexOf(this.currentSlide.get()!); if (currentIndex === 0) { return; } this.currentSlide.set(sortedSlides[currentIndex - 1]); } handleRemoteAction(action: RemoteSocketActions, data: any) { // Don't duplicate actions done locally, but there // might be a better way to do this if (data.userId === this.userId) { return; } switch (action) { case RemoteSocketActions.ENTER_ROOM: this.handleEnterRoom(data); break; case RemoteSocketActions.LEAVE_ROOM: this.handleLeaveRoom(data); break; case RemoteSocketActions.MOUSE_MOVEMENT: this.handleMouseMovement(data); break; case RemoteSocketActions.CREATE_SLIDE: this.handleCreateSlide(data); break; case RemoteSocketActions.DELETE_SLIDES: this.handleDeleteSlides(data); break; case RemoteSocketActions.MOUSE_CLICK: this.handleMouseClick(data); break; case RemoteSocketActions.CREATE_SLIDE_OBJECT: this.handleCreateSlideObject(data); break; case RemoteSocketActions.UPDATE_SLIDE_OBJECT: this.handleUpdateSlideObject(data); break; case RemoteSocketActions.DELETE_SLIDE_OBJECT: this.handleDeleteSlideObject(data); break; case RemoteSocketActions.SET_SELECTED_SLIDE_ELEMENT: this.handleSetSelectedSlideElement(data); break; default: this.handleOtherAction(action, data); } } @action handleMouseClick(data: MouseClickData) { set(this.mouseClicks, data.userId, { x: data.x, y: data.y, timestamp: data.timestamp, userId: data.userId, }); } getSelectedElementForUser(userId: ID) { for (const elementId of Array.from( this.subscriberSelectedElements.keys() )) { if (this.subscriberSelectedElements.get(elementId) === userId) { return elementId; } } return null; } }