Skip to content

Commit

Permalink
Implemented Document Collaborators & Added Fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
R1c4rdCo5t4 committed Jun 27, 2024
1 parent 84e3cf5 commit ab10f70
Show file tree
Hide file tree
Showing 28 changed files with 242 additions and 103 deletions.
1 change: 1 addition & 0 deletions code/client/src/ui/pages/document/Document.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.document {
position: relative;
}
6 changes: 3 additions & 3 deletions code/client/src/ui/pages/document/Document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useDocumentService from '@services/resource/useResourcesService';
import useConnectors from '@domain/editor/connectors/useConnectors';
import FloatingButtons from '@ui/pages/document/components/floating-buttons/FloatingButtons';
import { DocumentResource } from '@notespace/shared/src/workspace/types/resource';
import Collaborators from '@ui/pages/document/components/collaborators/Collaborators';
import './Document.scss';

function Document() {
Expand Down Expand Up @@ -43,15 +44,14 @@ function Document() {
setLoaded(true);
}
fetchDocument();
return () => {
socket.emit('leaveDocument');
};
return () => socket.emit('leaveDocument');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);

if (!loaded) return null;
return (
<div className="document">
<Collaborators />
<Editor title={title} fugue={fugue} connectors={connectors} />
<FloatingButtons />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.collaborators {
position: absolute;
top: 0;
right: 0;

> div {
width: 4vh;
height: 4vh;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
padding: 1vh;
margin: 1vh;

display: flex;
justify-content: center;
align-items: center;

a {
text-decoration: none !important;
color: white;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import useCollaborators from '@ui/pages/document/components/collaborators/useCollaborators';
import { Link } from 'react-router-dom';
import './Collaborators.scss';

function Collaborators() {
const collaborators = useCollaborators();
return (
<div className="collaborators">
{collaborators.map(collaborator => {
const nameParts = collaborator.name.split(' ');
const initials =
nameParts.length > 1 ? `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}` : nameParts[0][0];
return (
<div key={collaborator.id} title={collaborator.name} style={{ backgroundColor: collaborator.color }}>
<Link to={`/profile/${collaborator.id}`}>{initials}</Link>
</div>
);
})}
</div>
);
}

export default Collaborators;
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { Collaborator } from '@notespace/shared/src/users/types';
import useSocketListeners from '@services/communication/socket/useSocketListeners';
import { useCommunication } from '@/contexts/communication/useCommunication';

function useCollaborators() {
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const { socket } = useCommunication();

const onCollaboratorsJoined = (users: Collaborator[]) => {
setCollaborators(prev => [...prev, ...users]);
};

const onCollaboratorLeft = (id: string) => {
setCollaborators(prev => prev.filter(u => u.id !== id));
};

useSocketListeners(socket, {
joinedDocument: onCollaboratorsJoined,
leftDocument: onCollaboratorLeft,
});

return collaborators;
}

export default useCollaborators;
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DocumentResource } from '@notespace/shared/src/workspace/types/resource
import { Checkbox } from '@mui/material';
import { formatDate, formatTimePassed } from '@/utils/utils';
import { useEffect, useState } from 'react';
import useWorkspace from '@/contexts/workspace/useWorkspace';

type DocumentViewProps = {
document: DocumentResource;
Expand All @@ -19,6 +20,7 @@ function DocumentView({ document, onSelect, onDelete, onRename, onDuplicate, sel
const { wid } = useParams();
const { component, isEditing, setIsEditing } = useEditing(document.name || 'Untitled', onRename);
const [isSelected, setSelected] = useState(selected);
const { isMember } = useWorkspace();

useEffect(() => {
setSelected(selected);
Expand All @@ -43,6 +45,7 @@ function DocumentView({ document, onSelect, onDelete, onRename, onDuplicate, sel
onOpenInNewTab={() => window.open(`/workspaces/${wid}/${document.id}`, '_blank')}
onDuplicate={onDuplicate}
onDelete={onDelete}
enabled={isMember}
>
{isEditing ? DocumentComponent : <Link to={`/workspaces/${wid}/${document.id}`}>{DocumentComponent}</Link>}
</ResourceContextMenu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ function WorkspaceContextMenu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser]);

if (!isMember) return null;
return (
<PopupMenu item={children}>
<PopupMenu item={children} enabled={isMember}>
<button onClick={onRename}>
<MdEdit />
Rename
Expand Down
3 changes: 1 addition & 2 deletions code/server/src/controllers/http/handlers/usersHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ function usersHandlers(service: UsersService) {
} catch (e) {
// user not found, continue
const user = await admin.auth().getUser(uid);
const userData = { name: user.displayName!, email: user.email! };
await service.createUser(uid, userData);
await service.createUser(uid, user.displayName!, user.email!);
httpResponse.created(res).send();
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { NextFunction, Request, Response } from 'express';
import { httpResponse } from '@controllers/http/utils/httpResponse';
import { LoggedUser } from '@notespace/shared/src/users/types';
import { UserData } from '@notespace/shared/src/users/types';
import { ErrorLogger } from '@src/utils/logging';
import admin from 'firebase-admin';

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
user?: LoggedUser;
user?: UserData;
}
}
}
Expand Down Expand Up @@ -36,7 +36,7 @@ export async function enforceAuth(req: Request, res: Response, next: NextFunctio
next();
}

async function verifySessionCookie(sessionCookie: string): Promise<LoggedUser> {
async function verifySessionCookie(sessionCookie: string): Promise<UserData> {
const idToken = await admin.auth().verifySessionCookie(sessionCookie, true);
const { uid, displayName, email } = await admin.auth().getUser(idToken.uid);
return { id: uid, email: email!, name: displayName! };
Expand Down
19 changes: 12 additions & 7 deletions code/server/src/controllers/ws/events/document/onCursorChange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,27 @@ export function deleteCursor(socket: Socket, documentId: string) {
}

function updateCursor(socket: Socket, data: CursorData, documentId: string) {
const color = getColor(socket);
const color = getCursorColor(socket.id);
const socketData = { ...data, id: socket.id, color };

socket.broadcast.to(documentId).emit('cursorChange', socketData);
}

function getColor(socket: Socket) {
if (!cursorColorsMap.has(socket.id)) {
export function getCursorColor(socketId: string) {
if (!cursorColorsMap.has(socketId)) {
const randomColor = getRandomColor();
cursorColorsMap.set(socket.id, randomColor);
cursorColorsMap.set(socketId, randomColor);
}
return cursorColorsMap.get(socket.id);
return cursorColorsMap.get(socketId);
}

function getRandomColor() {
return 'hsl(' + Math.random() * 360 + ', 100%, 80%)';
function getRandomColor(): string {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}

export default onCursorChange;
24 changes: 23 additions & 1 deletion code/server/src/controllers/ws/events/document/onJoinDocument.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import { Socket } from 'socket.io';
import rooms from '@controllers/ws/rooms/rooms';
import { InvalidParameterError } from '@domain/errors/errors';
import { getUserFromSocket } from '@controllers/ws/utils';
import { getCursorColor } from '@controllers/ws/events/document/onCursorChange';

function onJoinDocument() {
return function (socket: Socket, documentId: string) {
if (!documentId) throw new InvalidParameterError('Document id is required');
rooms.document.join(socket, documentId);

// get user
const user = getUserFromSocket(socket);
if (!user) return;

// join the document room
rooms.document.join(socket, documentId, user);

// broadcast to all clients in the document
socket.in(documentId).emit('joinedDocument', [{ ...user, color: getCursorColor(socket.id) }]);

// send the clients that are already in the document to the new client
const room = rooms.document.getRoom(documentId)!;
const users = room
.getClients()
.map(client => ({
...client.user,
color: getCursorColor(client.socketId),
}))
.filter(u => u.id !== user.id);
socket.emit('joinedDocument', users);
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { Socket } from 'socket.io';
import rooms from '@controllers/ws/rooms/rooms';
import { deleteCursor } from '@controllers/ws/events/document/onCursorChange';
import { getUserFromSocket } from '@controllers/ws/utils';

function onLeaveDocument() {
return function (socket: Socket) {
const documentId = rooms.document.get(socket.id)?.id;
if (!documentId) return;

deleteCursor(socket, documentId);
// leave the document room
rooms.document.leave(socket);

// delete cursor
deleteCursor(socket, documentId);

// broadcast to all clients in the document
const user = getUserFromSocket(socket);
if (!user) return;
socket.in(documentId).emit('leftDocument', user.id);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function onJoinWorkspace(service: WorkspacesService) {
if (!user || !members.includes(user.email)) return;

// join the workspace room
rooms.workspace.join(socket, id);
rooms.workspace.join(socket, id, user);
};
}

Expand Down
10 changes: 9 additions & 1 deletion code/server/src/controllers/ws/initSocketEvents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { SocketHandler } from '@controllers/ws/types';
import { Socket } from 'socket.io';

import { ControllersLogger } from '@src/utils/logging';
import rooms from '@controllers/ws/rooms/rooms';
import onLeaveDocument from '@controllers/ws/events/document/onLeaveDocument';
import onLeaveWorkspace from '@controllers/ws/events/workspace/onLeaveWorkspace';

const logger = ControllersLogger('ws');

Expand All @@ -23,6 +25,12 @@ export default function initSocketEvents(events: Record<string, SocketHandler>)
});

socket.on('disconnect', reason => {
// check if the user is in a document room
const isInDocument = rooms.document.isInRoom(socket.id);
if (isInDocument) onLeaveDocument()(socket);
// check if the user is in a workspace room
const isInWorkspace = rooms.workspace.isInRoom(socket.id);
if (isInWorkspace) onLeaveWorkspace()(socket);
logger.logInfo('Client disconnected: ' + reason);
});
};
Expand Down
47 changes: 31 additions & 16 deletions code/server/src/controllers/ws/rooms/Room.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,54 @@
import { UserData } from '@notespace/shared/src/users/types';

type Client = {
socketId: string;
user: UserData;
};

/**
* Room class
* A room serves to isolate users from each other
* A user can join a room and leave it
* Users can only receive messages broadcast to the room they are in
* A room serves to isolate clients from each other
* A client can join a room and leave it
* Clients can only receive messages broadcast to the room they are in
*/
class Room {
private readonly roomId: string;
private users: string[] = [];
private clients: Client[] = [];

constructor(roomId: string) {
this.roomId = roomId;
}

/**
* Add a user to the room
* @param userId
* Add a client to the room
* @param socketId
* @param user
*/
join(socketId: string, user: UserData) {
this.clients.push({ socketId, user });
}

/**
* Remove a client from the room
* @param socketId
*/
join(userId: string) {
this.users.push(userId);
leave(socketId: string) {
this.clients = this.clients.filter(u => u.socketId !== socketId);
}

/**
* Remove a user from the room
* @param userId
* Check if a client is in the room
* @param socketId
*/
leave(userId: string) {
this.users = this.users.filter(id => id !== userId);
has(socketId: string) {
return this.clients.some(u => u.socketId === socketId);
}

/**
* Check if a user is in the room
* @param userId
* Get the clients in the room
*/
has(userId: string) {
return this.users.includes(userId);
getClients() {
return this.clients;
}

get id() {
Expand Down
11 changes: 6 additions & 5 deletions code/server/src/controllers/ws/rooms/operations.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Socket } from 'socket.io';
import Room from '@controllers/ws/rooms/Room';
import { UserData } from '@notespace/shared/src/users/types';

export function joinRoom(rooms: Map<string, Room>, socket: Socket, id: string) {
socket.join(id);
const room = rooms.get(id) || new Room(id);
room.join(socket.id);
rooms.set(id, room);
export function joinRoom(rooms: Map<string, Room>, roomId: string, socket: Socket, user: UserData) {
socket.join(roomId);
const room = rooms.get(roomId) || new Room(roomId);
room.join(socket.id, user);
rooms.set(roomId, room);
}

export function leaveRoom(rooms: Map<string, Room>, socket: Socket) {
Expand Down
Loading

0 comments on commit ab10f70

Please sign in to comment.