Skip to content

Commit 2ddd6ac

Browse files
authored
Merge pull request #210 from boostcampwm-2024/feature-fe-#206
유저를 관리하기 위한 웹소켓 Room 추가
2 parents 0772c25 + d489d0c commit 2ddd6ac

File tree

10 files changed

+200
-26
lines changed

10 files changed

+200
-26
lines changed

apps/frontend/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
23
import Sidebar from "./components/sidebar";
34
import HoverTrigger from "./components/HoverTrigger";
45
import EditorView from "./components/EditorView";
56
import SideWrapper from "./components/layout/SideWrapper";
67
import Canvas from "./components/canvas";
78
import ScrollWrapper from "./components/layout/ScrollWrapper";
89

10+
import { useSyncedUsers } from "./hooks/useSyncedUsers";
11+
912
const queryClient = new QueryClient();
1013

1114
function App() {
15+
useSyncedUsers();
16+
1217
return (
1318
<QueryClientProvider client={queryClient}>
1419
<div className="fixed inset-0 bg-white">

apps/frontend/src/components/CursorView.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useReactFlow } from "@xyflow/react";
33
import { type AwarenessState } from "@/hooks/useCursor";
44
import Cursor from "./cursor";
55
import { useMemo } from "react";
6+
import useUserStore from "@/store/useUserStore";
67

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

15+
const { currentUser } = useUserStore();
16+
1417
const validCursors = useMemo(
15-
() => Array.from(cursors.values()).filter((cursor) => cursor.cursor),
18+
() =>
19+
Array.from(cursors.values()).filter(
20+
(cursor) =>
21+
cursor.cursor &&
22+
(cursor.clientId as unknown as string) !== currentUser.clientId,
23+
),
1624
[cursors],
1725
);
1826

@@ -26,6 +34,7 @@ export function CollaborativeCursors({ cursors }: CollaborativeCursorsProps) {
2634
y: cursor.cursor!.y,
2735
})}
2836
color={cursor.color}
37+
clientId={cursor.clientId.toString()}
2938
/>
3039
))}
3140
</Panel>

apps/frontend/src/components/EditorView.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@ import * as Y from "yjs";
55
import { SocketIOProvider } from "y-socket.io";
66

77
import Editor from "./editor";
8-
import usePageStore from "@/store/usePageStore";
9-
import { usePage, useUpdatePage } from "@/hooks/usePages";
108
import EditorLayout from "./layout/EditorLayout";
119
import EditorTitle from "./editor/EditorTitle";
1210
import SaveStatus from "./editor/ui/SaveStatus";
11+
import ActiveUser from "./commons/activeUser";
12+
13+
import usePageStore from "@/store/usePageStore";
14+
import useUserStore from "@/store/useUserStore";
15+
import { usePage, useUpdatePage } from "@/hooks/usePages";
1316

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

2125
useEffect(() => {
2226
if (!currentPage) return;
@@ -88,6 +92,12 @@ export default function EditorView() {
8892
currentPage={currentPage}
8993
pageContent={pageContent}
9094
/>
95+
<ActiveUser
96+
className="px-12 py-4"
97+
users={users.filter(
98+
(user) => user.currentPageId === currentPage.toString(),
99+
)}
100+
/>
91101
<Editor
92102
key={ydoc.guid}
93103
initialContent={pageContent}

apps/frontend/src/components/canvas/NoteNode.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Handle, NodeProps, Position, type Node } from "@xyflow/react";
2+
3+
import ActiveUser from "../commons/activeUser";
4+
25
import usePageStore from "@/store/usePageStore";
6+
import useUserStore from "@/store/useUserStore";
37

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

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

1015
const handleNodeClick = () => {
1116
const id = data.id;
@@ -46,6 +51,12 @@ export function NoteNode({ data }: NodeProps<NoteNodeType>) {
4651
isConnectable={true}
4752
/>
4853
{data.title}
54+
<ActiveUser
55+
className="justify-end"
56+
users={users.filter(
57+
(user) => user.currentPageId === data.id.toString(),
58+
)}
59+
/>
4960
</div>
5061
);
5162
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { User } from "@/store/useUserStore";
2+
import { cn } from "@/lib/utils";
3+
4+
interface ActiveUserProps {
5+
users: User[];
6+
className?: string;
7+
}
8+
9+
export default function ActiveUser({ users, className }: ActiveUserProps) {
10+
return (
11+
<div className={cn("flex gap-2", className)}>
12+
{users.map((user) => (
13+
<div
14+
style={{ backgroundColor: user.color }}
15+
className={cn("group relative h-5 w-5 rounded-full")}
16+
key={user.clientId}
17+
>
18+
<div
19+
style={{ backgroundColor: user.color }}
20+
className="absolute left-2 z-10 hidden px-2 text-sm group-hover:flex"
21+
>
22+
{user.clientId}
23+
</div>
24+
</div>
25+
))}
26+
</div>
27+
);
28+
}

apps/frontend/src/components/cursor/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ export interface Coors {
77

88
interface CursorProps {
99
coors: Coors;
10+
clientId: string;
1011
color?: string;
1112
}
1213

13-
export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) {
14+
export default function Cursor({
15+
coors,
16+
color = "#ffb8b9",
17+
clientId,
18+
}: CursorProps) {
1419
const { x, y } = coors;
1520

1621
return (
@@ -35,6 +40,9 @@ export default function Cursor({ coors, color = "#ffb8b9" }: CursorProps) {
3540
strokeWidth="4"
3641
/>
3742
</svg>
43+
<p className="absolute px-1" style={{ backgroundColor: color }}>
44+
{clientId}
45+
</p>
3846
</motion.div>
3947
);
4048
}

apps/frontend/src/hooks/useCursor.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,7 @@ import { useReactFlow, type XYPosition } from "@xyflow/react";
22
import * as Y from "yjs";
33
import { useCallback, useEffect, useRef, useState } from "react";
44
import { SocketIOProvider } from "y-socket.io";
5-
6-
const CURSOR_COLORS = [
7-
"#7d7b94",
8-
"#41c76d",
9-
"#f86e7e",
10-
"#f6b8b8",
11-
"#f7d353",
12-
"#3b5bf7",
13-
"#59cbf7",
14-
] as const;
5+
import useUserStore from "@/store/useUserStore";
156

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

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

5745
provider.current = wsProvider;
58-
clientId.current = wsProvider.awareness.clientID;
5946

6047
wsProvider.awareness.setLocalState({
6148
cursor: null,
62-
color: userColor.current,
63-
clientId: wsProvider.awareness.clientID,
49+
color: currentUser.color,
50+
clientId: currentUser.clientId,
6451
});
6552

6653
wsProvider.awareness.on("change", () => {
6754
const states = new Map(
6855
Array.from(
6956
wsProvider.awareness.getStates() as Map<number, AwarenessState>,
70-
).filter(
71-
([key, state]) => key !== clientId.current && state.cursor !== null,
72-
),
57+
).filter(([, state]) => state.cursor !== null),
7358
);
7459
setCursors(states);
7560
});
@@ -90,8 +75,8 @@ export function useCollaborativeCursors({
9075

9176
provider.current.awareness.setLocalState({
9277
cursor,
93-
color: userColor.current,
94-
clientId: provider.current.awareness.clientID,
78+
color: currentUser.color,
79+
clientId: currentUser.clientId,
9580
});
9681
},
9782
[flowInstance],
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect } from "react";
2+
3+
import usePageStore from "@/store/usePageStore";
4+
import useUserStore, { User } from "@/store/useUserStore";
5+
6+
export const useSyncedUsers = () => {
7+
const { currentPage } = usePageStore();
8+
const { provider, currentUser, setCurrentUser, setUsers } = useUserStore();
9+
10+
const updateUsersFromAwareness = () => {
11+
const values = Array.from(
12+
provider.awareness.getStates().values(),
13+
) as User[];
14+
setUsers(values);
15+
};
16+
17+
const getLocalStorageUser = (): User | null => {
18+
const userData = localStorage.getItem("currentUser");
19+
return userData ? JSON.parse(userData) : null;
20+
};
21+
22+
useEffect(() => {
23+
if (currentPage === null) return;
24+
25+
const updatedUser: User = {
26+
...currentUser,
27+
currentPageId: currentPage.toString(),
28+
};
29+
30+
setCurrentUser(updatedUser);
31+
provider.awareness.setLocalState(updatedUser);
32+
}, [currentPage]);
33+
34+
useEffect(() => {
35+
const localStorageUser = getLocalStorageUser();
36+
37+
if (!localStorageUser) {
38+
localStorage.setItem("currentUser", JSON.stringify(currentUser));
39+
} else {
40+
setCurrentUser(localStorageUser);
41+
provider.awareness.setLocalState(localStorageUser);
42+
}
43+
44+
updateUsersFromAwareness();
45+
46+
provider.awareness.on("change", updateUsersFromAwareness);
47+
48+
return () => {
49+
provider.awareness.off("change", updateUsersFromAwareness);
50+
};
51+
}, []);
52+
};

apps/frontend/src/lib/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,23 @@ import { twMerge } from "tailwind-merge";
44
export function cn(...inputs: ClassValue[]) {
55
return twMerge(clsx(inputs));
66
}
7+
8+
export function getRandomColor() {
9+
const COLORS = [
10+
"#7d7b94",
11+
"#41c76d",
12+
"#f86e7e",
13+
"#f6b8b8",
14+
"#f7d353",
15+
"#3b5bf7",
16+
"#59cbf7",
17+
] as const;
18+
19+
return COLORS[Math.floor(Math.random() * COLORS.length)];
20+
}
21+
22+
export function getRandomHexString(length = 10) {
23+
return [...Array(length)]
24+
.map(() => Math.floor(Math.random() * 16).toString(16))
25+
.join("");
26+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { create } from "zustand";
2+
import * as Y from "yjs";
3+
import { SocketIOProvider } from "y-socket.io";
4+
5+
import { getRandomColor, getRandomHexString } from "@/lib/utils";
6+
7+
export interface User {
8+
clientId: string;
9+
color: string;
10+
currentPageId: string | null;
11+
}
12+
13+
interface UserStore {
14+
provider: SocketIOProvider;
15+
users: User[];
16+
currentUser: User;
17+
setUsers: (users: User[]) => void;
18+
setCurrentUser: (user: User) => void;
19+
}
20+
21+
const useUserStore = create<UserStore>((set) => ({
22+
provider: new SocketIOProvider(
23+
import.meta.env.VITE_WS_URL,
24+
`users`,
25+
new Y.Doc(),
26+
{
27+
autoConnect: true,
28+
disableBc: false,
29+
},
30+
{
31+
reconnectionDelayMax: 10000,
32+
timeout: 5000,
33+
transports: ["websocket", "polling"],
34+
},
35+
),
36+
users: [],
37+
currentUser: {
38+
clientId: getRandomHexString(10),
39+
color: getRandomColor(),
40+
currentPageId: null,
41+
},
42+
setUsers: (users: User[]) => set({ users }),
43+
setCurrentUser: (user: User) => set({ currentUser: user }),
44+
}));
45+
46+
export default useUserStore;

0 commit comments

Comments
 (0)