Skip to content

Commit

Permalink
Merge pull request #210 from boostcampwm-2024/feature-fe-#206
Browse files Browse the repository at this point in the history
유저를 관리하기 위한 웹소켓 Room 추가
djk01281 authored Nov 19, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 0772c25 + d489d0c commit 2ddd6ac
Showing 10 changed files with 200 additions and 26 deletions.
5 changes: 5 additions & 0 deletions apps/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import Sidebar from "./components/sidebar";
import HoverTrigger from "./components/HoverTrigger";
import EditorView from "./components/EditorView";
import SideWrapper from "./components/layout/SideWrapper";
import Canvas from "./components/canvas";
import ScrollWrapper from "./components/layout/ScrollWrapper";

import { useSyncedUsers } from "./hooks/useSyncedUsers";

const queryClient = new QueryClient();

function App() {
useSyncedUsers();

return (
<QueryClientProvider client={queryClient}>
<div className="fixed inset-0 bg-white">
11 changes: 10 additions & 1 deletion apps/frontend/src/components/CursorView.tsx
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { useReactFlow } from "@xyflow/react";
import { type AwarenessState } from "@/hooks/useCursor";
import Cursor from "./cursor";
import { useMemo } from "react";
import useUserStore from "@/store/useUserStore";

interface CollaborativeCursorsProps {
cursors: Map<number, AwarenessState>;
@@ -11,8 +12,15 @@ interface CollaborativeCursorsProps {
export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) {
const { flowToScreenPosition } = useReactFlow();

const { currentUser } = useUserStore();

const validCursors = useMemo(
() => Array.from(cursors.values()).filter((cursor) => cursor.cursor),
() =>
Array.from(cursors.values()).filter(
(cursor) =>
cursor.cursor &&
(cursor.clientId as unknown as string) !== currentUser.clientId,
),
[cursors],
);

@@ -26,6 +34,7 @@ export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) {
y: cursor.cursor!.y,
})}
color={cursor.color}
clientId={cursor.clientId.toString()}
/>
))}
</Panel>
14 changes: 12 additions & 2 deletions apps/frontend/src/components/EditorView.tsx
Original file line number Diff line number Diff line change
@@ -5,18 +5,22 @@ import * as Y from "yjs";
import { SocketIOProvider } from "y-socket.io";

import Editor from "./editor";
import usePageStore from "@/store/usePageStore";
import { usePage, useUpdatePage } from "@/hooks/usePages";
import EditorLayout from "./layout/EditorLayout";
import EditorTitle from "./editor/EditorTitle";
import SaveStatus from "./editor/ui/SaveStatus";
import ActiveUser from "./commons/activeUser";

import usePageStore from "@/store/usePageStore";
import useUserStore from "@/store/useUserStore";
import { usePage, useUpdatePage } from "@/hooks/usePages";

export default function EditorView() {
const { currentPage } = usePageStore();
const { page, isLoading } = usePage(currentPage);
const [saveStatus, setSaveStatus] = useState<"saved" | "unsaved">("saved");
const [ydoc, setYDoc] = useState<Y.Doc | null>(null);
const [provider, setProvider] = useState<SocketIOProvider | null>(null);
const { users } = useUserStore();

useEffect(() => {
if (!currentPage) return;
@@ -88,6 +92,12 @@ export default function EditorView() {
currentPage={currentPage}
pageContent={pageContent}
/>
<ActiveUser
className="px-12 py-4"
users={users.filter(
(user) => user.currentPageId === currentPage.toString(),
)}
/>
<Editor
key={ydoc.guid}
initialContent={pageContent}
11 changes: 11 additions & 0 deletions apps/frontend/src/components/canvas/NoteNode.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Handle, NodeProps, Position, type Node } from "@xyflow/react";

import ActiveUser from "../commons/activeUser";

import usePageStore from "@/store/usePageStore";
import useUserStore from "@/store/useUserStore";

export type NoteNodeData = { title: string; id: number };
export type NoteNodeType = Node<NoteNodeData, "note">;

export function NoteNode({ data }: NodeProps<NoteNodeType>) {
const { setCurrentPage } = usePageStore();
const { users } = useUserStore();

const handleNodeClick = () => {
const id = data.id;
@@ -46,6 +51,12 @@ export function NoteNode({ data }: NodeProps<NoteNodeType>) {
isConnectable={true}
/>
{data.title}
<ActiveUser
className="justify-end"
users={users.filter(
(user) => user.currentPageId === data.id.toString(),
)}
/>
</div>
);
}
28 changes: 28 additions & 0 deletions apps/frontend/src/components/commons/activeUser/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { User } from "@/store/useUserStore";
import { cn } from "@/lib/utils";

interface ActiveUserProps {
users: User[];
className?: string;
}

export default function ActiveUser({ users, className }: ActiveUserProps) {
return (
<div className={cn("flex gap-2", className)}>
{users.map((user) => (
<div
style={{ backgroundColor: user.color }}
className={cn("group relative h-5 w-5 rounded-full")}
key={user.clientId}
>
<div
style={{ backgroundColor: user.color }}
className="absolute left-2 z-10 hidden px-2 text-sm group-hover:flex"
>
{user.clientId}
</div>
</div>
))}
</div>
);
}
10 changes: 9 additions & 1 deletion apps/frontend/src/components/cursor/index.tsx
Original file line number Diff line number Diff line change
@@ -7,10 +7,15 @@ export interface Coors {

interface CursorProps {
coors: Coors;
clientId: string;
color?: string;
}

export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) {
export default function Cursor({
coors,
color = "#ffb8b9",
clientId,
}: CursorProps) {
const { x, y } = coors;

return (
@@ -35,6 +40,9 @@ export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) {
strokeWidth="4"
/>
</svg>
<p className="absolute px-1" style={{ backgroundColor: color }}>
{clientId}
</p>
</motion.div>
);
}
29 changes: 7 additions & 22 deletions apps/frontend/src/hooks/useCursor.ts
Original file line number Diff line number Diff line change
@@ -2,16 +2,7 @@ import { useReactFlow, type XYPosition } from "@xyflow/react";
import * as Y from "yjs";
import { useCallback, useEffect, useRef, useState } from "react";
import { SocketIOProvider } from "y-socket.io";

const CURSOR_COLORS = [
"#7d7b94",
"#41c76d",
"#f86e7e",
"#f6b8b8",
"#f7d353",
"#3b5bf7",
"#59cbf7",
] as const;
import useUserStore from "@/store/useUserStore";

export interface AwarenessState {
cursor: XYPosition | null;
@@ -33,10 +24,7 @@ export function useCollaborativeCursors({
const [cursors, setCursors] = useState<Map<number, AwarenessState>>(
new Map(),
);
const clientId = useRef<number | null>(null);
const userColor = useRef(
CURSOR_COLORS[Math.floor(Math.random() * CURSOR_COLORS.length)],
);
const { currentUser } = useUserStore();

useEffect(() => {
const wsProvider = new SocketIOProvider(
@@ -55,21 +43,18 @@ export function useCollaborativeCursors({
);

provider.current = wsProvider;
clientId.current = wsProvider.awareness.clientID;

wsProvider.awareness.setLocalState({
cursor: null,
color: userColor.current,
clientId: wsProvider.awareness.clientID,
color: currentUser.color,
clientId: currentUser.clientId,
});

wsProvider.awareness.on("change", () => {
const states = new Map(
Array.from(
wsProvider.awareness.getStates() as Map<number, AwarenessState>,
).filter(
([key, state]) => key !== clientId.current && state.cursor !== null,
),
).filter(([, state]) => state.cursor !== null),
);
setCursors(states);
});
@@ -90,8 +75,8 @@ export function useCollaborativeCursors({

provider.current.awareness.setLocalState({
cursor,
color: userColor.current,
clientId: provider.current.awareness.clientID,
color: currentUser.color,
clientId: currentUser.clientId,
});
},
[flowInstance],
52 changes: 52 additions & 0 deletions apps/frontend/src/hooks/useSyncedUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect } from "react";

import usePageStore from "@/store/usePageStore";
import useUserStore, { User } from "@/store/useUserStore";

export const useSyncedUsers = () => {
const { currentPage } = usePageStore();
const { provider, currentUser, setCurrentUser, setUsers } = useUserStore();

const updateUsersFromAwareness = () => {
const values = Array.from(
provider.awareness.getStates().values(),
) as User[];
setUsers(values);
};

const getLocalStorageUser = (): User | null => {
const userData = localStorage.getItem("currentUser");
return userData ? JSON.parse(userData) : null;
};

useEffect(() => {
if (currentPage === null) return;

const updatedUser: User = {
...currentUser,
currentPageId: currentPage.toString(),
};

setCurrentUser(updatedUser);
provider.awareness.setLocalState(updatedUser);
}, [currentPage]);

useEffect(() => {
const localStorageUser = getLocalStorageUser();

if (!localStorageUser) {
localStorage.setItem("currentUser", JSON.stringify(currentUser));
} else {
setCurrentUser(localStorageUser);
provider.awareness.setLocalState(localStorageUser);
}

updateUsersFromAwareness();

provider.awareness.on("change", updateUsersFromAwareness);

return () => {
provider.awareness.off("change", updateUsersFromAwareness);
};
}, []);
};
20 changes: 20 additions & 0 deletions apps/frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -4,3 +4,23 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export function getRandomColor() {
const COLORS = [
"#7d7b94",
"#41c76d",
"#f86e7e",
"#f6b8b8",
"#f7d353",
"#3b5bf7",
"#59cbf7",
] as const;

return COLORS[Math.floor(Math.random() * COLORS.length)];
}

export function getRandomHexString(length = 10) {
return [...Array(length)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("");
}
46 changes: 46 additions & 0 deletions apps/frontend/src/store/useUserStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { create } from "zustand";
import * as Y from "yjs";
import { SocketIOProvider } from "y-socket.io";

import { getRandomColor, getRandomHexString } from "@/lib/utils";

export interface User {
clientId: string;
color: string;
currentPageId: string | null;
}

interface UserStore {
provider: SocketIOProvider;
users: User[];
currentUser: User;
setUsers: (users: User[]) => void;
setCurrentUser: (user: User) => void;
}

const useUserStore = create<UserStore>((set) => ({
provider: new SocketIOProvider(
import.meta.env.VITE_WS_URL,
`users`,
new Y.Doc(),
{
autoConnect: true,
disableBc: false,
},
{
reconnectionDelayMax: 10000,
timeout: 5000,
transports: ["websocket", "polling"],
},
),
users: [],
currentUser: {
clientId: getRandomHexString(10),
color: getRandomColor(),
currentPageId: null,
},
setUsers: (users: User[]) => set({ users }),
setCurrentUser: (user: User) => set({ currentUser: user }),
}));

export default useUserStore;

0 comments on commit 2ddd6ac

Please sign in to comment.