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 (
<>
+
-
+ )}
))}
@@ -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 (
+
+ );
+});
+
+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();