ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый 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:
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user