From 8cc7d39146c3b3f05c23b7fba9f33684dd098f52 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 27 Apr 2024 15:40:22 +0100 Subject: [PATCH] websocket updates * sync page title on icon via websocket * sync on page tree too --- apps/client/src/App.tsx | 38 ++++++++++++ .../layouts/components/breadcrumb.tsx | 2 +- .../editor/hooks/use-collaboration-url.ts | 4 +- .../src/features/editor/title-editor.tsx | 15 +++++ .../page/tree/components/space-tree.tsx | 20 ++++++ .../features/websocket/atoms/socket-atom.ts | 4 ++ .../src/features/websocket/types/constants.ts | 3 + .../src/features/websocket/types/index.ts | 2 + .../src/features/websocket/types/types.ts | 14 +++++ .../src/features/websocket/use-query-emit.ts | 11 ++++ .../websocket/use-query-subscription.ts | 43 +++++++++++++ .../src/features/websocket/use-tree-socket.ts | 62 +++++++++++++++++++ apps/client/src/lib/api-client.ts | 3 +- apps/server/src/ws/ws.gateway.ts | 41 ++++++++---- apps/server/src/ws/ws.module.ts | 2 + package.json | 1 + pnpm-lock.yaml | 3 + 17 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/features/websocket/atoms/socket-atom.ts create mode 100644 apps/client/src/features/websocket/types/constants.ts create mode 100644 apps/client/src/features/websocket/types/index.ts create mode 100644 apps/client/src/features/websocket/types/types.ts create mode 100644 apps/client/src/features/websocket/use-query-emit.ts create mode 100644 apps/client/src/features/websocket/use-query-subscription.ts create mode 100644 apps/client/src/features/websocket/use-tree-socket.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 0b2b7d75..66c167dc 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -13,8 +13,46 @@ import Groups from "@/pages/settings/group/groups"; import GroupInfo from "./pages/settings/group/group-info"; import Spaces from "@/pages/settings/space/spaces.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; +import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts"; +import { useAtom, useAtomValue } from "jotai"; +import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; +import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; +import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts"; +import { SOCKET_URL } from "@/features/websocket/types"; export default function App() { + const [, setSocket] = useAtom(socketAtom); + const authToken = useAtomValue(authTokensAtom); + + useEffect(() => { + if (!authToken?.accessToken) { + return; + } + const newSocket = io(SOCKET_URL, { + transports: ["websocket"], + auth: { + token: authToken.accessToken, + }, + }); + + // @ts-ignore + setSocket(newSocket); + + newSocket.on("connect", () => { + console.log("ws connected"); + }); + + return () => { + console.log("ws disconnected"); + newSocket.disconnect(); + }; + }, [authToken?.accessToken]); + + useQuerySubscription(); + useTreeSocket(); + return ( <> diff --git a/apps/client/src/components/layouts/components/breadcrumb.tsx b/apps/client/src/components/layouts/components/breadcrumb.tsx index 0dc26a80..9fa79a1f 100644 --- a/apps/client/src/components/layouts/components/breadcrumb.tsx +++ b/apps/client/src/components/layouts/components/breadcrumb.tsx @@ -17,7 +17,7 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts"; function getTitle(name: string, icon: string) { if (icon) { - return `${icon} ${name}`; + return `${icon} ${name}`; } return name; } diff --git a/apps/client/src/features/editor/hooks/use-collaboration-url.ts b/apps/client/src/features/editor/hooks/use-collaboration-url.ts index 978b9add..750be5b5 100644 --- a/apps/client/src/features/editor/hooks/use-collaboration-url.ts +++ b/apps/client/src/features/editor/hooks/use-collaboration-url.ts @@ -13,7 +13,9 @@ const useCollaborationURL = (): string => { } */ - const API_URL = window.location.protocol + "//" + window.location.host; + const API_URL = import.meta.env.DEV + ? "http://localhost:3000" + : window.location.protocol + "//" + window.location.host; const wsProtocol = API_URL.startsWith("https") ? "wss" : "ws"; return `${wsProtocol}://${API_URL.split("://")[1]}${PATH}`; diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index 0358eac5..475f4ade 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -15,6 +15,8 @@ import { useDebouncedValue } from "@mantine/hooks"; import { useAtom } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { updateTreeNodeName } from "@/features/page/tree/utils"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; +import { History } from "@tiptap/extension-history"; export interface TitleEditorProps { pageId: string; @@ -28,6 +30,7 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) { const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const [treeData, setTreeData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); const titleEditor = useEditor({ extensions: [ @@ -41,6 +44,9 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) { Placeholder.configure({ placeholder: "Untitled", }), + History.configure({ + depth: 20, + }), ], onCreate({ editor }) { if (editor) { @@ -59,6 +65,15 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) { if (debouncedTitle !== "") { updatePageMutation.mutate({ pageId, title: debouncedTitle }); + setTimeout(() => { + emit({ + operation: "updateOne", + entity: ["pages"], + id: pageId, + payload: { title: debouncedTitle }, + }); + }, 50); + const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle); setTreeData(newTreeData); } diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 74400a7a..384669b7 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -41,6 +41,7 @@ import { queryClient } from "@/main.tsx"; import { OpenMap } from "react-arborist/dist/main/state/open-slice"; import { useElementSize, useMergedRef } from "@mantine/hooks"; import { dfs } from "react-arborist/dist/module/utils"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; interface SpaceTreeProps { spaceId: string; @@ -205,6 +206,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const navigate = useNavigate(); const updatePageMutation = useUpdatePageMutation(); const [treeData, setTreeData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); async function handleLoadChildren(node: NodeApi) { if (!node.data.hasChildren) return; @@ -255,11 +257,29 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { const handleEmojiSelect = (emoji: { native: string }) => { handleUpdateNodeIcon(node.id, emoji.native); updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native }); + + setTimeout(() => { + emit({ + operation: "updateOne", + entity: ["pages"], + id: node.id, + payload: { icon: emoji.native }, + }); + }, 50); }; const handleRemoveEmoji = () => { handleUpdateNodeIcon(node.id, null); updatePageMutation.mutateAsync({ pageId: node.id, icon: null }); + + setTimeout(() => { + emit({ + operation: "updateOne", + entity: ["pages"], + id: node.id, + payload: { icon: null }, + }); + }, 50); }; if (node.willReceiveDrop && node.isClosed) { diff --git a/apps/client/src/features/websocket/atoms/socket-atom.ts b/apps/client/src/features/websocket/atoms/socket-atom.ts new file mode 100644 index 00000000..3f7c32d3 --- /dev/null +++ b/apps/client/src/features/websocket/atoms/socket-atom.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import { Socket } from "socket.io-client"; + +export const socketAtom = atom(null); diff --git a/apps/client/src/features/websocket/types/constants.ts b/apps/client/src/features/websocket/types/constants.ts new file mode 100644 index 00000000..ec6e3c3d --- /dev/null +++ b/apps/client/src/features/websocket/types/constants.ts @@ -0,0 +1,3 @@ +export const SOCKET_URL = import.meta.env.DEV + ? "http://localhost:3000" + : undefined; diff --git a/apps/client/src/features/websocket/types/index.ts b/apps/client/src/features/websocket/types/index.ts new file mode 100644 index 00000000..627cac15 --- /dev/null +++ b/apps/client/src/features/websocket/types/index.ts @@ -0,0 +1,2 @@ +export * from "./types.ts"; +export * from "./constants.ts"; diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts new file mode 100644 index 00000000..fc2754b4 --- /dev/null +++ b/apps/client/src/features/websocket/types/types.ts @@ -0,0 +1,14 @@ +export type InvalidateEvent = { + operation: "invalidate"; + entity: Array; + id?: string; +}; + +export type UpdateEvent = { + operation: "updateOne"; + entity: Array; + id: string; + payload: Partial; +}; + +export type WebSocketEvent = InvalidateEvent | UpdateEvent; diff --git a/apps/client/src/features/websocket/use-query-emit.ts b/apps/client/src/features/websocket/use-query-emit.ts new file mode 100644 index 00000000..d251025a --- /dev/null +++ b/apps/client/src/features/websocket/use-query-emit.ts @@ -0,0 +1,11 @@ +import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; +import { useAtom } from "jotai"; +import { WebSocketEvent } from "@/features/websocket/types"; + +export const useQueryEmit = () => { + const [socket] = useAtom(socketAtom); + + return (input: WebSocketEvent) => { + socket?.emit("message", input); + }; +}; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts new file mode 100644 index 00000000..228b4848 --- /dev/null +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -0,0 +1,43 @@ +import React from "react"; +import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; +import { useAtom } from "jotai"; +import { useQueryClient } from "@tanstack/react-query"; +import { WebSocketEvent } from "@/features/websocket/types"; + +export const useQuerySubscription = () => { + const queryClient = useQueryClient(); + const [socket] = useAtom(socketAtom); + + React.useEffect(() => { + socket?.on("message", (event) => { + const data: WebSocketEvent = event; + + switch (data.operation) { + case "invalidate": + queryClient.invalidateQueries({ + queryKey: [...data.entity, data.id].filter(Boolean), + }); + break; + case "updateOne": + queryClient.setQueryData([...data.entity, data.id], { + ...queryClient.getQueryData([...data.entity, data.id]), + ...data.payload, + }); + + /* + queryClient.setQueriesData( + { queryKey: [data.entity, data.id] }, + (oldData: any) => { + const update = (entity: Record) => + entity.id === data.id ? { ...entity, ...data.payload } : entity; + return Array.isArray(oldData) + ? oldData.map(update) + : update(oldData as Record); + }, + ); + */ + break; + } + }); + }, [queryClient, socket]); +}; diff --git a/apps/client/src/features/websocket/use-tree-socket.ts b/apps/client/src/features/websocket/use-tree-socket.ts new file mode 100644 index 00000000..2fa4fb96 --- /dev/null +++ b/apps/client/src/features/websocket/use-tree-socket.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; +import { useAtom } from "jotai"; +import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; +import { + updateTreeNodeIcon, + updateTreeNodeName, +} from "@/features/page/tree/utils"; +import { WebSocketEvent } from "@/features/websocket/types"; +import { SpaceTreeNode } from "@/features/page/tree/types.ts"; + +export const useTreeSocket = () => { + const [socket] = useAtom(socketAtom); + const [treeData, setTreeData] = useAtom(treeDataAtom); + + const initialTreeData = useRef(treeData); + + useEffect(() => { + initialTreeData.current = treeData; + }, [treeData]); + + useEffect(() => { + socket?.on("message", (event) => { + const data: WebSocketEvent = event; + + const initialData = initialTreeData.current; + switch (data.operation) { + case "invalidate": + // nothing to do here + break; + case "updateOne": + // Get the initial value of treeData + if (initialData && initialData.length > 0) { + let newTreeData: SpaceTreeNode[]; + + if (data.entity[0] === "pages") { + if (data.payload?.title !== undefined) { + newTreeData = updateTreeNodeName( + initialData, + data.id, + data.payload.title, + ); + } + + if (data.payload?.icon !== undefined) { + newTreeData = updateTreeNodeIcon( + initialData, + data.id, + data.payload.icon, + ); + } + + if (newTreeData && newTreeData.length > 0) { + setTreeData(newTreeData); + } + } + } + break; + } + }); + }, [socket]); +}; diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index b522d769..49c078a4 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -2,8 +2,9 @@ import axios, { AxiosInstance } from "axios"; import Cookies from "js-cookie"; import Routes from "@/lib/routes"; +const baseUrl = import.meta.env.DEV ? "http://localhost:3000" : ""; const api: AxiosInstance = axios.create({ - baseURL: "/api", + baseURL: baseUrl + "/api", }); api.interceptors.request.use( diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts index f8391a8d..70355ce2 100644 --- a/apps/server/src/ws/ws.gateway.ts +++ b/apps/server/src/ws/ws.gateway.ts @@ -1,27 +1,44 @@ import { OnGatewayConnection, - OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; -import { Server } from 'socket.io'; +import { Server, Socket } from 'socket.io'; +import { TokenService } from '../core/auth/services/token.service'; +import { JwtType } from '../core/auth/dto/jwt-payload'; +import { OnModuleDestroy } from '@nestjs/common'; -@WebSocketGateway({ namespace: 'events' }) -export class WsGateway implements OnGatewayInit, OnGatewayConnection { +@WebSocketGateway({ + cors: { origin: '*' }, + transports: ['websocket'], +}) +export class WsGateway implements OnGatewayConnection, OnModuleDestroy { @WebSocketServer() server: Server; + constructor(private tokenService: TokenService) {} + + async handleConnection(client: Socket, ...args: any[]): Promise { + try { + const token = await this.tokenService.verifyJwt( + client.handshake.auth?.token, + ); + if (token.type !== JwtType.ACCESS) { + client.disconnect(); + } + } catch (err) { + client.disconnect(); + } + } @SubscribeMessage('message') - handleMessage(client: any, payload: any): string { - return 'Hello world!'; + handleMessage(client: Socket, data: string): void { + client.broadcast.emit('message', data); } - handleConnection(client: any, ...args: any[]): any { - // - } - - afterInit(server: any): any { - // + onModuleDestroy() { + if (this.server) { + this.server.close(); + } } } diff --git a/apps/server/src/ws/ws.module.ts b/apps/server/src/ws/ws.module.ts index 058d3dff..a0d60b3f 100644 --- a/apps/server/src/ws/ws.module.ts +++ b/apps/server/src/ws/ws.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { WsGateway } from './ws.gateway'; +import { AuthModule } from '../core/auth/auth.module'; @Module({ + imports: [AuthModule], providers: [WsGateway], }) export class WsModule {} diff --git a/package.json b/package.json index 0020c900..93f92bbf 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tiptap/extension-document": "^2.3.0", "@tiptap/extension-heading": "^2.3.0", "@tiptap/extension-highlight": "^2.3.0", + "@tiptap/extension-history": "^2.3.0", "@tiptap/extension-link": "^2.3.0", "@tiptap/extension-list-item": "^2.3.0", "@tiptap/extension-list-keymap": "^2.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32da0dd9..02d71639 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tiptap/extension-highlight': specifier: ^2.3.0 version: 2.3.0(@tiptap/core@2.3.0) + '@tiptap/extension-history': + specifier: ^2.3.0 + version: 2.3.0(@tiptap/core@2.3.0)(@tiptap/pm@2.3.0) '@tiptap/extension-link': specifier: ^2.3.0 version: 2.3.0(@tiptap/core@2.3.0)(@tiptap/pm@2.3.0)