ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker

Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
DCCONSTRUCTIONS
2026-05-12 17:28:40 +03:00
parent fc59481703
commit 480f85cce8
23 changed files with 993 additions and 72 deletions
@@ -0,0 +1,238 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { useSWRConfig } from "swr";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
type TNodeDCRealtimeEvent = {
event_id?: string;
type?: string;
workspace_slug?: string;
project_id?: string;
member_id?: string;
affected_user_ids?: string[];
project_ids?: string[];
};
const MAX_PROCESSED_EVENTS = 250;
const buildNodeDCStreamUrl = () => {
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
const url = new URL(liveBaseUrl);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `${liveBasePath.replace(/\/$/, "")}/nodedc/stream`;
return url.toString();
};
const isWorkspacePath = (pathname: string | null, workspaceSlug: string) =>
pathname === `/${workspaceSlug}` || !!pathname?.startsWith(`/${workspaceSlug}/`);
const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId: string) =>
pathname === `/${workspaceSlug}/projects/${projectId}` ||
!!pathname?.startsWith(`/${workspaceSlug}/projects/${projectId}/`);
const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => {
if (!currentUserId) return false;
if (event.member_id === currentUserId) return true;
return event.affected_user_ids?.includes(currentUserId) ?? false;
};
export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string) => {
const pathname = usePathname();
const router = useAppRouter();
const { mutate } = useSWRConfig();
const workspaceRoot = useWorkspace();
const projectStore = useProject();
const memberRoot = useMember();
const userStore = useUser();
const pathnameRef = useRef(pathname);
const currentUserIdRef = useRef(currentUserId);
const processedEventIdsRef = useRef<string[]>([]);
const processedEventSetRef = useRef(new Set<string>());
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
useEffect(() => {
currentUserIdRef.current = currentUserId;
}, [currentUserId]);
useEffect(() => {
if (!enabled || typeof window === "undefined") return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
let reconnectAttempt = 0;
const rememberEvent = (eventId?: string) => {
if (!eventId) return true;
if (processedEventSetRef.current.has(eventId)) return false;
processedEventIdsRef.current.push(eventId);
processedEventSetRef.current.add(eventId);
if (processedEventIdsRef.current.length > MAX_PROCESSED_EVENTS) {
const removedEventId = processedEventIdsRef.current.shift();
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
}
return true;
};
const refreshWorkspaceScope = async (event: TNodeDCRealtimeEvent) => {
const workspaceSlug = event.workspace_slug;
if (!workspaceSlug) return;
await Promise.allSettled([
workspaceRoot.fetchWorkspaces(),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug),
projectStore.fetchProjects(workspaceSlug),
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
]);
if (
event.type === "workspace_member.deleted" &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isWorkspacePath(pathnameRef.current, workspaceSlug)
) {
router.replace(workspaceRoot.getWorkspaceRedirectionUrl());
}
};
const refreshProjectScope = async (event: TNodeDCRealtimeEvent) => {
const workspaceSlug = event.workspace_slug;
const projectId = event.project_id;
if (!workspaceSlug) return;
await Promise.allSettled([
workspaceRoot.fetchWorkspaces(),
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
projectStore.fetchProjects(workspaceSlug),
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(),
]);
if (
projectId &&
event.type === "project_member.deleted" &&
isCurrentUserAffected(event, currentUserIdRef.current) &&
isProjectPath(pathnameRef.current, workspaceSlug, projectId)
) {
router.replace(`/${workspaceSlug}`);
}
};
const refreshInviteScope = async (event: TNodeDCRealtimeEvent) => {
await Promise.allSettled([
mutate("USER_WORKSPACE_INVITATIONS_NOTICE"),
event.workspace_slug
? memberRoot.workspace.fetchWorkspaceMemberInvitations(event.workspace_slug)
: Promise.resolve(),
]);
};
const refreshProfileScope = async (event: TNodeDCRealtimeEvent) => {
await Promise.allSettled([
isCurrentUserAffected(event, currentUserIdRef.current) ? userStore.fetchCurrentUser() : Promise.resolve(),
event.workspace_slug ? memberRoot.workspace.fetchWorkspaceMembers(event.workspace_slug) : Promise.resolve(),
event.workspace_slug && event.project_id
? memberRoot.project.fetchProjectMembers(event.workspace_slug, event.project_id, true)
: Promise.resolve(),
mutate("USER_INFORMATION"),
]);
};
const handleEvent = async (event: TNodeDCRealtimeEvent) => {
if (!rememberEvent(event.event_id)) return;
if (event.type?.startsWith("workspace_member.")) {
await refreshWorkspaceScope(event);
return;
}
if (event.type?.startsWith("project_member.")) {
await refreshProjectScope(event);
return;
}
if (event.type?.startsWith("workspace_invite.")) {
await refreshInviteScope(event);
return;
}
if (event.type === "user.profile.updated") {
await refreshProfileScope(event);
}
};
const scheduleReconnect = () => {
if (cancelled) return;
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
reconnectAttempt += 1;
reconnectTimer = setTimeout(connect, delay);
};
const connect = () => {
try {
socket = new WebSocket(buildNodeDCStreamUrl());
socket.addEventListener("open", () => {
reconnectAttempt = 0;
});
socket.addEventListener("message", (message) => {
try {
const event = JSON.parse(message.data) as TNodeDCRealtimeEvent;
if (event.type === "nodedc.stream.ping") {
socket?.send(JSON.stringify({ type: "nodedc.stream.pong" }));
return;
}
if (event.type === "nodedc.stream.ready") return;
void handleEvent(event);
} catch (error) {
console.error("Failed to process NODE.DC realtime event", error);
}
});
socket.addEventListener("close", () => {
socket = undefined;
scheduleReconnect();
});
socket.addEventListener("error", () => {
socket?.close();
});
} catch (error) {
console.error("Failed to connect NODE.DC realtime stream", error);
scheduleReconnect();
}
};
connect();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
}, [enabled, memberRoot, mutate, projectStore, router, userStore, workspaceRoot]);
};