diff --git a/client/src/Bootstrap.tsx b/client/src/Bootstrap.tsx index c514ebe..15acc7a 100644 --- a/client/src/Bootstrap.tsx +++ b/client/src/Bootstrap.tsx @@ -23,7 +23,6 @@ const Bootstrap = () => { const { setCanvasElementState } = useCanvasElementStore([ 'setCanvasElementState', ]); - const { setColorMaping } = useCommentsStore(['setColorMaping']); const [isLoaded, setIsLoaded] = useState(false); const auth = async () => { const token = localStorage.getItem(ACCESS_TOKEN_TAG); @@ -38,7 +37,6 @@ const Bootstrap = () => { setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, ) )?.valueOf(); if (response.status === HTTP_STATUS.SUCCESS) { diff --git a/client/src/WebsocketClient.ts b/client/src/WebsocketClient.ts index f70b6aa..d942bc2 100644 --- a/client/src/WebsocketClient.ts +++ b/client/src/WebsocketClient.ts @@ -175,7 +175,7 @@ export default class WebsocketClient { * Method that sends a message over the WebSocket connection. * @param msg Object, the message object to be sent. */ - async send(msg: object) { + async send(msg: Record) { if (!this.checkSocket()) return; try { await this.waitForOpenConnection(); @@ -216,13 +216,14 @@ export default class WebsocketClient { | string[] | UpdatedTimeMessage | null - | { elemID: string; comment: Partial }, + | { elemID: string; comment: Partial } + | Record, ) { // Msg to be changed to proper type once everything finalized if (this.room === null) throw 'No room assigned!'; return this.send({ ...this.msgTemplate, - topic: topic, + topic, payload: msg, room: this.room, id: this.userId, diff --git a/client/src/components/lib/BoardScroll.tsx b/client/src/components/lib/BoardScroll.tsx index c4d30ac..6e8ec0a 100644 --- a/client/src/components/lib/BoardScroll.tsx +++ b/client/src/components/lib/BoardScroll.tsx @@ -72,7 +72,6 @@ export const BoardScroll = () => { 'setCanvasElementState', ]); const { userID } = useAuthStore(['userID']); - const { setColorMaping } = useCommentsStore(['setColorMaping']); const setCanvasState = () => { state && setCanvasElementState(state); @@ -101,8 +100,6 @@ export const BoardScroll = () => { params: { id: board.id, userID }, }); - setColorMaping(boardState.data.board.collaborators); - collabID = boardState.data.collabID; const state = createStateWithRoughElement( diff --git a/client/src/components/lib/Canvas.tsx b/client/src/components/lib/Canvas.tsx index d28bd96..e1573d3 100644 --- a/client/src/components/lib/Canvas.tsx +++ b/client/src/components/lib/Canvas.tsx @@ -39,7 +39,9 @@ import { normalizeAngle } from '@/lib/math'; import { useCanvasBoardStore } from '@/stores/CanavasBoardStore'; import { tenancy } from '@/api'; import { useAuthStore } from '@/stores/AuthStore'; -import { useCommentsStore } from '@/stores/CommentsStore'; +import CursorPresence from './CursorPresence'; +import { throttle } from 'lodash'; +import { idToColour } from '@/lib/userColours'; /** * Main Canvas View @@ -132,8 +134,6 @@ export default function Canvas() { 'attachedFileUrls', ]); - const { addColor } = useCommentsStore(['addColor']); - const { socket, setWebsocketAction, setRoomID, setTenants, clearTenants } = useWebSocketStore([ 'socket', @@ -161,13 +161,7 @@ export default function Canvas() { * Callbacks for active tenants in the room. */ useEffect(() => { - socket?.on(WS_TOPICS.NOTIFY_JOIN_ROOM, (payload) => { - initTenants(); - const collabID = extractCollabID( - (payload as { payload: { id: string } }).payload.id, - ); - collabID && addColor(collabID); - }); + socket?.on(WS_TOPICS.NOTIFY_JOIN_ROOM, initTenants); socket?.on(WS_TOPICS.NOTIFY_LEAVE_ROOM, initTenants); return clearTenants; }, [socket]); @@ -180,19 +174,22 @@ export default function Canvas() { */ const initTenants = async () => { const tenantIds = (await tenancy.get(boardMeta.roomID)) as string[]; + console.log(tenantIds); const activeTenants = tenantIds.reduce( (acc, id) => { + const collabId = extractCollabID(id); + if (collabId === null) return acc; + + const isMe = collabId === boardMeta.collabID; // We don't want to add ourselves to the list of tenants. - id !== userEmail && + !isMe && (acc[id] = { // Temp username: extractUsername(id) ?? id, email: id, initials: 'A', avatar: 'https://github.com/shadcn.png', - outlineColor: `#${Math.floor(Math.random() * 16777215).toString( - 16, - )}`, + outlineColor: idToColour(collabId), }); return acc; }, @@ -517,12 +514,28 @@ export default function Canvas() { action !== 'writing' && setAction('none'); }; + const sendCursorPositions = React.useCallback( + throttle((x: number | null, y: number | null) => { + socket?.sendMsgRoom( + 'updateCursorPosition', + // make collab id + { + x, + y, + userId: userEmail, + }, + ); + }, 16), + [userEmail, socket], + ); + // eslint-disable-next-line sonarjs/cognitive-complexity const handleMouseMove = (e: MouseEvent) => { + const { clientX, clientY } = getMouseCoordinates(e); + sendCursorPositions(clientX, clientY); if (e.button === PERIPHERAL_CODES.RIGHT_MOUSE) { return; } - const { clientX, clientY } = getMouseCoordinates(e); if (action === 'panning') { const deltaX = clientX - panMouseStartPosition.current.x; @@ -684,6 +697,7 @@ export default function Canvas() { return ( <> + { + sendCursorPositions(null, null); + }} /> {tool === 'select' && attachedFileUrls[selectedElementIds[0]] !== undefined && ( diff --git a/client/src/components/lib/CommentsSheetContent.tsx b/client/src/components/lib/CommentsSheetContent.tsx index 3143599..f3fb13e 100644 --- a/client/src/components/lib/CommentsSheetContent.tsx +++ b/client/src/components/lib/CommentsSheetContent.tsx @@ -12,6 +12,7 @@ import { useCanvasBoardStore } from '@/stores/CanavasBoardStore'; import { useCanvasElementStore } from '@/stores/CanvasElementsStore'; import { useWebSocketStore } from '@/stores/WebSocketStore'; import { useAppStore } from '@/stores/AppStore'; +import { idToColour } from '@/lib/userColours'; /** * Defines a CommentsSheetContent component that displays the comments of a board, and allows the user to add a comment or like existing ones. @@ -136,21 +137,14 @@ const CommentsSheetContent = () => { 'angles', 'fileIds', ]); - const { - comments, - colorMaping, - updateComment, - addComment, - removeComment, - setComments, - } = useCommentsStore([ - 'comments', - 'colorMaping', - 'updateComment', - 'addComment', - 'removeComment', - 'setComments', - ]); + const { comments, updateComment, addComment, removeComment, setComments } = + useCommentsStore([ + 'comments', + 'updateComment', + 'addComment', + 'removeComment', + 'setComments', + ]); const { isViewingComments } = useAppStore(['isViewingComments']); @@ -191,7 +185,9 @@ const CommentsSheetContent = () => { setComments( comments.data.comments.map((comment: Comment) => ({ ...comment, - outlineColor: `${colorMaping[comment.outlineColor]}`, + collabId: comment.outlineColor, + // Outline colour is the collaborator id + outlineColor: `${idToColour(comment.outlineColor)}`, })), ); }; @@ -270,25 +266,27 @@ const CommentsSheetContent = () => {

- + setWebsocketAction( + { + comment: { uid: comment.uid }, + elemID: selectedElementIds[0], + }, + 'removeComment', + ); + }} + > + + + )} ))} @@ -356,7 +354,8 @@ const CommentsSheetContent = () => { comment: comment.data.comment.comment, likes: 0, initials: getInitials(`${userFirstName} ${userLastName}`), - outlineColor: `${colorMaping[boardMeta.collabID]}`, + outlineColor: `${idToColour(boardMeta.collabID)}`, + collabId: boardMeta.collabID, isLiked: false, }; diff --git a/client/src/components/lib/CursorPresence.tsx b/client/src/components/lib/CursorPresence.tsx new file mode 100644 index 0000000..935709d --- /dev/null +++ b/client/src/components/lib/CursorPresence.tsx @@ -0,0 +1,74 @@ +import { extractCollabID, extractUsername } from '@/lib/misc'; +import { idToColour } from '@/lib/userColours'; +import { useWebSocketStore } from '@/stores/WebSocketStore'; +import { Vector2 } from '@/types'; +import { MousePointer2 } from 'lucide-react'; +import React, { memo, useMemo } from 'react'; + +const Cursor = memo(({ userId, x, y }: Vector2 & { userId: string }) => { + const { userName, cursorColour } = useMemo( + () => ({ + userName: extractUsername(userId) ?? userId, + cursorColour: idToColour(extractCollabID(userId) ?? userId), + }), + [userId], + ); + return ( + + +
+ {userName} +
+
+ ); +}); +Cursor.displayName = 'CursorPresence'; + +const CursorPresence = memo(() => { + const { cursorPositions } = useWebSocketStore(['cursorPositions']); + return ( + + + {Object.entries(cursorPositions).map( + ([userId, position]) => + position.x !== null && + position.y !== null && ( + + ), + )} + + + ); +}); + +CursorPresence.displayName = 'CursorPresence'; +export default CursorPresence; diff --git a/client/src/components/lib/TopBar.tsx b/client/src/components/lib/TopBar.tsx index 8c8d70d..713fd96 100644 --- a/client/src/components/lib/TopBar.tsx +++ b/client/src/components/lib/TopBar.tsx @@ -8,7 +8,6 @@ import { useAppStore } from '@/stores/AppStore'; import axios from 'axios'; import { REST } from '@/constants'; import { useAuthStore } from '@/stores/AuthStore'; -import { useCommentsStore } from '@/stores/CommentsStore'; /** * Define a react component that the top bar of the main dashboard @@ -24,7 +23,6 @@ export const TopBar = () => { ]); const { userID } = useAuthStore(['userID']); const { setMode } = useAppStore(['setMode']); - const { setColorMaping } = useCommentsStore(['setColorMaping']); return (
@@ -90,8 +88,6 @@ export const TopBar = () => { addCanvas(boardData); - setColorMaping(boardData.collaborators); - setBoardMeta({ roomID: boardData.roomID, title: boardData.title, diff --git a/client/src/hooks/useSocket.tsx b/client/src/hooks/useSocket.tsx index c4fe721..6e1c019 100644 --- a/client/src/hooks/useSocket.tsx +++ b/client/src/hooks/useSocket.tsx @@ -11,6 +11,7 @@ import { fileCache } from '@/lib/cache'; import { dataURLToFile } from '@/lib/bytes'; import { commitImageToCache, isSupportedImageFile } from '@/lib/image'; import { useCanvasBoardStore } from '@/stores/CanavasBoardStore'; +import { Vector2 } from '@/types'; /** * Defines a hook that controls all socket related activities @@ -19,14 +20,21 @@ import { useCanvasBoardStore } from '@/stores/CanavasBoardStore'; export const useSocket = () => { const { userEmail: userId } = useAuthStore(['userEmail']); - const { roomID, actionElementID, action, setSocket, setWebsocketAction } = - useWebSocketStore([ - 'roomID', - 'actionElementID', - 'action', - 'setSocket', - 'setWebsocketAction', - ]); + const { + roomID, + actionElementID, + action, + setSocket, + setWebsocketAction, + setCursorPosition, + } = useWebSocketStore([ + 'roomID', + 'actionElementID', + 'action', + 'setSocket', + 'setWebsocketAction', + 'setCursorPosition', + ]); const { addCanvasShape, @@ -214,6 +222,11 @@ export const useSocket = () => { setBoardMeta({ lastModified: fields.lastModified }); updateCanvas(fields.boardID, fields.lastModified); }, + updateCursorPosition: (cursorPosition: Vector2 & { userId: string }) => + setCursorPosition(cursorPosition.userId, { + x: cursorPosition.x, + y: cursorPosition.y, + }), }; // Intialize socket diff --git a/client/src/lib/userColours.ts b/client/src/lib/userColours.ts new file mode 100644 index 0000000..f698c0c --- /dev/null +++ b/client/src/lib/userColours.ts @@ -0,0 +1,31 @@ +/** + * This file contains the logic to generate a random colour for a user based on their id. + * @author Yousef Yassin + */ + +/** + * An array of 20 random colours to be used for user avatars. + */ +const userColours = new Array(20) + .fill(0) + .map(() => `#${Math.floor(Math.random() * 16777215).toString(16)}`); + +/** + * Hashes a string to an index between 0 and 19 to be used to select a colour from the userColours array. + * @param str The string to be hashed + * @returns The index of the userColours array to be used for the given string + */ +const hashStringToIndex = (str: string): number => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash += str.charCodeAt(i); + } + return hash % 20; // Modulo operation to ensure the index is within 0 to 19 +}; + +/** + * Retrieves a colour from the userColours array based on the given id. + * @param id The id to be used to select a colour + * @returns The colour to be used for the given id + */ +export const idToColour = (id: string) => userColours[hashStringToIndex(id)]; diff --git a/client/src/stores/CommentsStore.ts b/client/src/stores/CommentsStore.ts index 7791d7f..8be72e6 100644 --- a/client/src/stores/CommentsStore.ts +++ b/client/src/stores/CommentsStore.ts @@ -10,6 +10,7 @@ import { createStoreWithSelectors } from './utils'; /** Boards blueprint */ export interface Comment { uid: string; + collabId: string; username: string; avatar: string; time: string; @@ -23,13 +24,10 @@ export interface Comment { /** Definitions */ interface CommentsState { comments: Comment[]; - colorMaping: Record; } interface CommentsActions { - setColorMaping: (collabs: string[]) => void; setComments: (comments: Comment[]) => void; - addColor: (collabID: string) => void; addComment: (comment: Comment) => void; removeComment: (id: string) => void; updateComment: (newComment: Partial) => void; @@ -40,22 +38,9 @@ type CommentsStore = CommentsActions & CommentsState; // Initialize CanvasBoard State to default state. export const initialCommentsState: CommentsState = { comments: [], - colorMaping: {}, }; /** Actions / Reducers */ -const setColorMaping = (set: SetState) => (collabs: string[]) => - set(() => { - const colorMaping = collabs.reduce( - (result: Record, entry: string) => { - result[entry] = `#${Math.floor(Math.random() * 16777215).toString(16)}`; - return result; - }, - {}, - ); - - return { colorMaping }; - }); const setComments = (set: SetState) => (comments: Comment[]) => set(() => ({ comments: comments.sort((a, b) => { @@ -66,18 +51,6 @@ const setComments = (set: SetState) => (comments: Comment[]) => }), })); -const addColor = (set: SetState) => (collabID: string) => - set((state) => { - if (state.colorMaping[collabID]) return state; - - const colorMaping = { - ...state.colorMaping, - [collabID]: `#${Math.floor(Math.random() * 16777215).toString(16)}`, - }; - - return { colorMaping }; - }); - const addComment = (set: SetState) => (comment: Comment) => set((state) => { const comments = [...state.comments, comment]; @@ -107,9 +80,7 @@ const updateComment = /** Store Hook */ const CommentsStore = create()((set) => ({ ...initialCommentsState, - setColorMaping: setColorMaping(set), setComments: setComments(set), - addColor: addColor(set), addComment: addComment(set), removeComment: removeComment(set), updateComment: updateComment(set), diff --git a/client/src/stores/WebSocketStore.ts b/client/src/stores/WebSocketStore.ts index 8be6e57..a3686d7 100644 --- a/client/src/stores/WebSocketStore.ts +++ b/client/src/stores/WebSocketStore.ts @@ -4,6 +4,7 @@ import { createStoreWithSelectors } from './utils'; import WebsocketClient from '@/WebsocketClient'; import { getInitials } from '@/lib/misc'; import { Comment } from './CommentsStore'; +import { Vector2 } from '@/types'; /** * Define Global WebSocket states and reducers @@ -57,6 +58,8 @@ interface WebSocketState { | { elemID: string; comment: Partial }; // The current active tenants activeTenants: Record; + // The current cursor positions keyed by user ID + cursorPositions: Record; // The current active producer ID activeProducerID: string | null; } @@ -77,6 +80,8 @@ interface WebSocketActions { setSocket: (socket: WebsocketClient) => void; // Set the active tenants setTenants: (tenants: Record) => void; + // Set the cursor positions + setCursorPosition: (userId: string, position: Vector2) => void; // Clear the active tenants clearTenants: () => void; // Set the active producer ID @@ -92,6 +97,7 @@ export const initialWebSocketState: WebSocketState = { action: '', actionElementID: '', activeTenants: {}, + cursorPositions: {}, activeProducerID: null, }; @@ -122,6 +128,13 @@ const clearTenants = (set: SetState) => () => const setActiveProducerId = (set: SetState) => (producerId: string | null) => set(() => ({ activeProducerID: producerId })); +const setCursorPosition = + (set: SetState) => (userId: string, position: Vector2) => + set((state) => { + const cursorPositions = { ...state.cursorPositions }; + cursorPositions[userId] = position; + return { cursorPositions }; + }); /** Store Hook */ const WebSocketStore = create()((set) => ({ @@ -132,6 +145,7 @@ const WebSocketStore = create()((set) => ({ setTenants: setTenants(set), clearTenants: clearTenants(set), setActiveProducerId: setActiveProducerId(set), + setCursorPosition: setCursorPosition(set), })); export const useWebSocketStore = createStoreWithSelectors(WebSocketStore); diff --git a/client/src/views/SignInPage.tsx b/client/src/views/SignInPage.tsx index 1f7caae..8aa9ba8 100644 --- a/client/src/views/SignInPage.tsx +++ b/client/src/views/SignInPage.tsx @@ -89,7 +89,6 @@ export async function getUserDetails( }>, ) => void, setCanvasElementState: (element: CanvasElementState) => void, - setColorMaping: (collabs: string[]) => void, ) { try { const user = await axios.get(REST.user.get, { @@ -113,7 +112,6 @@ export async function getUserDetails( setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, ); } catch (error) { console.error('Error:', error); @@ -136,7 +134,6 @@ export const checkURL = async ( }>, ) => void, setCanvasElementState: (element: CanvasElementState) => void, - setColorMaping: (collabs: string[]) => void, signUp = false, ) => { const queryParams = new URLSearchParams(window.location.search); @@ -149,9 +146,6 @@ export const checkURL = async ( id: queryParams.get('boardID'), fields: { collaborators: userID }, }); - setColorMaping(board.data.collaborators); - - setColorMaping(board.data.collaborators); setBoardMeta({ roomID: board.data.roomID, @@ -205,7 +199,6 @@ export default function SignInPage() { const { setCanvasElementState } = useCanvasElementStore([ 'setCanvasElementState', ]); - const { setColorMaping } = useCommentsStore(['setColorMaping']); const emailRef = useRef(null); const passwordRef = useRef(null); const [loading, setLoading] = useState(false); // State to disable sign in button while loading @@ -228,7 +221,6 @@ export default function SignInPage() { setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, ) )?.valueOf(); //get name, email, avatar of user @@ -277,7 +269,6 @@ export default function SignInPage() { setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, ) ).valueOf(); diff --git a/client/src/views/SignUpPage.tsx b/client/src/views/SignUpPage.tsx index f42518a..78dc87f 100644 --- a/client/src/views/SignUpPage.tsx +++ b/client/src/views/SignUpPage.tsx @@ -84,7 +84,6 @@ export default function SignUp() { const { setCanvasElementState } = useCanvasElementStore([ 'setCanvasElementState', ]); - const { setColorMaping } = useCommentsStore(['setColorMaping']); const emailRef = useRef(null); const passwordRef = useRef(null); const firstNameRef = useRef(null); @@ -157,7 +156,6 @@ export default function SignUp() { setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, true, ) ).valueOf(); @@ -214,7 +212,6 @@ export default function SignUp() { setCanvases, setBoardMeta, setCanvasElementState, - setColorMaping, true, ) ).valueOf();