mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 08:12:32 +10:00
websocket updates
* sync page title on icon via websocket * sync on page tree too
This commit is contained in:
@ -13,8 +13,46 @@ import Groups from "@/pages/settings/group/groups";
|
|||||||
import GroupInfo from "./pages/settings/group/group-info";
|
import GroupInfo from "./pages/settings/group/group-info";
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.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() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@ -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";
|
const wsProtocol = API_URL.startsWith("https") ? "wss" : "ws";
|
||||||
return `${wsProtocol}://${API_URL.split("://")[1]}${PATH}`;
|
return `${wsProtocol}://${API_URL.split("://")[1]}${PATH}`;
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { useDebouncedValue } from "@mantine/hooks";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
import { updateTreeNodeName } from "@/features/page/tree/utils";
|
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 {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@ -28,6 +30,7 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
|||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
const titleEditor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -41,6 +44,9 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: "Untitled",
|
placeholder: "Untitled",
|
||||||
}),
|
}),
|
||||||
|
History.configure({
|
||||||
|
depth: 20,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
onCreate({ editor }) {
|
onCreate({ editor }) {
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@ -59,6 +65,15 @@ export function TitleEditor({ pageId, title }: TitleEditorProps) {
|
|||||||
if (debouncedTitle !== "") {
|
if (debouncedTitle !== "") {
|
||||||
updatePageMutation.mutate({ pageId, title: debouncedTitle });
|
updatePageMutation.mutate({ pageId, title: debouncedTitle });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
emit({
|
||||||
|
operation: "updateOne",
|
||||||
|
entity: ["pages"],
|
||||||
|
id: pageId,
|
||||||
|
payload: { title: debouncedTitle },
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
|
||||||
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
const newTreeData = updateTreeNodeName(treeData, pageId, debouncedTitle);
|
||||||
setTreeData(newTreeData);
|
setTreeData(newTreeData);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import { queryClient } from "@/main.tsx";
|
|||||||
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
|
||||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||||
import { dfs } from "react-arborist/dist/module/utils";
|
import { dfs } from "react-arborist/dist/module/utils";
|
||||||
|
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -205,6 +206,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||||
if (!node.data.hasChildren) return;
|
if (!node.data.hasChildren) return;
|
||||||
@ -255,11 +257,29 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
handleUpdateNodeIcon(node.id, emoji.native);
|
handleUpdateNodeIcon(node.id, emoji.native);
|
||||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: 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 = () => {
|
const handleRemoveEmoji = () => {
|
||||||
handleUpdateNodeIcon(node.id, null);
|
handleUpdateNodeIcon(node.id, null);
|
||||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: 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) {
|
if (node.willReceiveDrop && node.isClosed) {
|
||||||
|
|||||||
4
apps/client/src/features/websocket/atoms/socket-atom.ts
Normal file
4
apps/client/src/features/websocket/atoms/socket-atom.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
|
||||||
|
export const socketAtom = atom<Socket | null>(null);
|
||||||
3
apps/client/src/features/websocket/types/constants.ts
Normal file
3
apps/client/src/features/websocket/types/constants.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const SOCKET_URL = import.meta.env.DEV
|
||||||
|
? "http://localhost:3000"
|
||||||
|
: undefined;
|
||||||
2
apps/client/src/features/websocket/types/index.ts
Normal file
2
apps/client/src/features/websocket/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types.ts";
|
||||||
|
export * from "./constants.ts";
|
||||||
14
apps/client/src/features/websocket/types/types.ts
Normal file
14
apps/client/src/features/websocket/types/types.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export type InvalidateEvent = {
|
||||||
|
operation: "invalidate";
|
||||||
|
entity: Array<string>;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateEvent = {
|
||||||
|
operation: "updateOne";
|
||||||
|
entity: Array<string>;
|
||||||
|
id: string;
|
||||||
|
payload: Partial<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebSocketEvent = InvalidateEvent | UpdateEvent;
|
||||||
11
apps/client/src/features/websocket/use-query-emit.ts
Normal file
11
apps/client/src/features/websocket/use-query-emit.ts
Normal file
@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
43
apps/client/src/features/websocket/use-query-subscription.ts
Normal file
43
apps/client/src/features/websocket/use-query-subscription.ts
Normal file
@ -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<string, unknown>) =>
|
||||||
|
entity.id === data.id ? { ...entity, ...data.payload } : entity;
|
||||||
|
return Array.isArray(oldData)
|
||||||
|
? oldData.map(update)
|
||||||
|
: update(oldData as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [queryClient, socket]);
|
||||||
|
};
|
||||||
62
apps/client/src/features/websocket/use-tree-socket.ts
Normal file
62
apps/client/src/features/websocket/use-tree-socket.ts
Normal file
@ -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]);
|
||||||
|
};
|
||||||
@ -2,8 +2,9 @@ import axios, { AxiosInstance } from "axios";
|
|||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
import Routes from "@/lib/routes";
|
import Routes from "@/lib/routes";
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.DEV ? "http://localhost:3000" : "";
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: baseUrl + "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
|
|||||||
@ -1,27 +1,44 @@
|
|||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayInit,
|
|
||||||
SubscribeMessage,
|
SubscribeMessage,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from '@nestjs/websockets';
|
} 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' })
|
@WebSocketGateway({
|
||||||
export class WsGateway implements OnGatewayInit, OnGatewayConnection {
|
cors: { origin: '*' },
|
||||||
|
transports: ['websocket'],
|
||||||
|
})
|
||||||
|
export class WsGateway implements OnGatewayConnection, OnModuleDestroy {
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
server: Server;
|
server: Server;
|
||||||
|
constructor(private tokenService: TokenService) {}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const token = await this.tokenService.verifyJwt(
|
||||||
|
client.handshake.auth?.token,
|
||||||
|
);
|
||||||
|
if (token.type !== JwtType.ACCESS) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
handleMessage(client: any, payload: any): string {
|
handleMessage(client: Socket, data: string): void {
|
||||||
return 'Hello world!';
|
client.broadcast.emit('message', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: any, ...args: any[]): any {
|
onModuleDestroy() {
|
||||||
//
|
if (this.server) {
|
||||||
}
|
this.server.close();
|
||||||
|
}
|
||||||
afterInit(server: any): any {
|
|
||||||
//
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WsGateway } from './ws.gateway';
|
import { WsGateway } from './ws.gateway';
|
||||||
|
import { AuthModule } from '../core/auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
providers: [WsGateway],
|
providers: [WsGateway],
|
||||||
})
|
})
|
||||||
export class WsModule {}
|
export class WsModule {}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@tiptap/extension-document": "^2.3.0",
|
"@tiptap/extension-document": "^2.3.0",
|
||||||
"@tiptap/extension-heading": "^2.3.0",
|
"@tiptap/extension-heading": "^2.3.0",
|
||||||
"@tiptap/extension-highlight": "^2.3.0",
|
"@tiptap/extension-highlight": "^2.3.0",
|
||||||
|
"@tiptap/extension-history": "^2.3.0",
|
||||||
"@tiptap/extension-link": "^2.3.0",
|
"@tiptap/extension-link": "^2.3.0",
|
||||||
"@tiptap/extension-list-item": "^2.3.0",
|
"@tiptap/extension-list-item": "^2.3.0",
|
||||||
"@tiptap/extension-list-keymap": "^2.3.0",
|
"@tiptap/extension-list-keymap": "^2.3.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -41,6 +41,9 @@ importers:
|
|||||||
'@tiptap/extension-highlight':
|
'@tiptap/extension-highlight':
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0(@tiptap/core@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':
|
'@tiptap/extension-link':
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0(@tiptap/core@2.3.0)(@tiptap/pm@2.3.0)
|
version: 2.3.0(@tiptap/core@2.3.0)(@tiptap/pm@2.3.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user