ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый 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
@@ -6,7 +6,6 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertTriangle } from "lucide-react";
// types
import { Button } from "@plane/propel/button";
@@ -22,12 +21,11 @@ type Props = {
onSubmit: () => Promise<void>;
isOpen: boolean;
onClose: () => void;
projectId: string;
};
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
const { data, onSubmit, isOpen, onClose } = props;
// router
const { projectId } = useParams();
const { data, onSubmit, isOpen, onClose, projectId } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// store hooks
@@ -50,44 +48,62 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
if (!projectId) return <></>;
const isCurrentUser = currentUser?.id === data?.id;
const currentProjectDetails = getProjectById(projectId.toString());
const currentProjectDetails = getProjectById(projectId);
const memberName = data?.display_name || "участника";
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.XXL}
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
>
<div className="p-6 sm:p-7">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-16 leading-6 font-medium text-primary">
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
<div className="min-w-0">
<p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
<h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
</h3>
<div className="mt-2">
<p className="text-13 text-secondary">
{isCurrentUser ? (
<>
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
project? You will be able to join the project if invited again or if it{"'"}s public.
</>
) : (
<>
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
They will no longer have access to this project. This action cannot be undone.
</>
)}
</p>
</div>
<p className="mt-3 text-13 leading-6 text-secondary">
{isCurrentUser ? (
<>
Вы потеряете доступ к проекту{" "}
<span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
будет только после нового приглашения или если проект публичный.
</>
) : (
<>
Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
проекту. Действие нельзя отменить автоматически.
</>
)}
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
</Button>
<div className="mt-7 flex justify-end gap-3">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отмена
</Button>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isDeleteLoading}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
</Button>
</div>
</div>
</ModalCore>
);
@@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(async () => {
router.push(`/${workspaceSlug}/projects`);
return undefined;
})
.catch((err) => {
setToast({
@@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
title: "You cant leave this project yet.",
message: err?.error || "Something went wrong. Please try again.",
});
return undefined;
});
} else
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
@@ -73,6 +75,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
<ConfirmProjectMemberRemove
isOpen={removeMemberModal !== null}
onClose={() => setRemoveMemberModal(null)}
projectId={projectId}
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
onSubmit={() => handleRemove(removeMemberModal.member.id)}
/>
@@ -82,7 +85,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
columns={columns}
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
@@ -69,12 +69,12 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
<SearchSelectionDropdown
value={value}
label={
<div className="flex h-3.5 items-center gap-2">
<div className="flex min-h-5 min-w-0 items-center gap-2">
{selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)}
{selectedOption ? (
selectedOption.member?.display_name
<span className="truncate">{selectedOption.member?.display_name}</span>
) : (
<div className="flex items-center gap-2">
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
@@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { MemberSelect } from "./member-select";
@@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
// store hooks
const { allowPermissions } = useUserPermissions();
const {
project: { fetchProjectMembers, getProjectMemberFetchStatus },
} = useMember();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
@@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
useEffect(() => {
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
useEffect(() => {
if (!currentProjectDetails) return;
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
@@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
{workspaceSlug && activeProjectId ? (
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
@@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
size="sm"
className="min-h-0 flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
<div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
<ProjectSettingsModalContent
activeTab={activeTab}
projectId={activeProjectId}
@@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
</div>
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
<div className="flex justify-end gap-3 p-4 sm:px-6">
<Button
variant="secondary"
size="lg"
onClick={handleClose}
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
>
Отменить
</Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button
variant="primary"
size="lg"
onClick={handleDeletion}
loading={isRemoving}
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
>
{currentUser?.id === userDetails.id
? isRemoving
? "Выход..."
@@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
if (isEmpty(columns)) return <MembersLayoutLoader />;
return (
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
<div className="w-full overflow-visible rounded-[1.2rem]">
{removeMemberModal && (
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal.member.id.length > 0}
@@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
}
keyExtractor={(rowData) => rowData?.member.id ?? ""}
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
tHeadClassName="border-b border-white/6"
thClassName="text-left font-medium divide-x-0 text-placeholder"
tBodyClassName="divide-y-0"
@@ -49,7 +49,14 @@ import {
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
"general",
"members",
"export",
"storage",
"webhooks",
"ai-voice-tasker",
]);
const workspaceAIService = new WorkspaceAIService();
const workspaceService = new WorkspaceService();
@@ -77,7 +84,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const { t } = useTranslation();
const canLoadVoiceTaskerEntitlement =
!!currentWorkspace?.slug &&
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
allowPermissions(
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
EUserPermissionsLevel.WORKSPACE,
currentWorkspace.slug
);
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
@@ -94,7 +105,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
setIsOpen(Boolean(tab));
if (tab) setActiveTab(tab);
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
setActiveWebhookId(
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
);
};
const handleModalEvent = (event: Event) => {
@@ -162,7 +175,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
}
if (activeTab === "webhooks" && currentWorkspace?.slug) {
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
return (
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
);
}
return <WorkspaceDetails />;
@@ -178,7 +193,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.VIIXL}
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
>
<div className="flex h-full min-h-0">
<div className="hidden h-full w-[296px] shrink-0 md:block">
@@ -212,7 +227,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
</div>
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
<div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
</ScrollArea>
</div>
</div>
@@ -260,7 +275,7 @@ function WorkspaceModalSidebar({
return (
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
{t(category)}
</div>
<div className="flex flex-col">
@@ -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]);
};
@@ -18,6 +18,7 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useNodeDCRealtimeEvents } from "@/hooks/use-nodedc-realtime-events";
// services
import { WorkspaceService } from "@/services/workspace.service";
@@ -64,6 +65,8 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
const shouldWatchPendingInvites =
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
useNodeDCRealtimeEvents(pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard, currentUser?.id);
const { data: pendingWorkspaceInvitations } = useSWR(
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
() => workspaceService.userWorkspaceInvitations(),
@@ -79,7 +82,12 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
const inviteKey = pendingWorkspaceInvitations
.map((invitation) => invitation.id)
.toSorted()
.reduce<string[]>((sortedInvitationIds, invitationId) => {
const insertAt = sortedInvitationIds.findIndex((sortedInvitationId) => sortedInvitationId > invitationId);
if (insertAt === -1) sortedInvitationIds.push(invitationId);
else sortedInvitationIds.splice(insertAt, 0, invitationId);
return sortedInvitationIds;
}, [])
.join(":");
if (pendingInviteToastKey.current === inviteKey) return;
pendingInviteToastKey.current = inviteKey;
@@ -236,15 +236,17 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
fetchWorkspaceMembers = async (workspaceSlug: string) =>
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
runInAction(() => {
const nextWorkspaceMemberMap: Record<string, IWorkspaceMembership> = {};
response.forEach((member) => {
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
nextWorkspaceMemberMap[member.member.id] = {
id: member.id,
member: member.member.id,
role: member.role,
role: member.role as EUserPermissions,
is_active: member.is_active,
});
};
});
set(this.workspaceMemberMap, workspaceSlug, nextWorkspaceMemberMap);
});
return response;
});
@@ -341,6 +341,20 @@ export class ProjectStore implements IProjectStore {
}
const projectsResponse = await this.projectService.getProjects(workspaceSlug);
runInAction(() => {
const workspaceId = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id;
const projectIds = new Set(projectsResponse.map((project) => project.id));
if (workspaceId) {
Object.values(this.projectMap)
.filter((project) => project.workspace === workspaceId && !projectIds.has(project.id))
.forEach((project) => {
delete this.projectMap[project.id];
if (this.rootStore.user.permission.workspaceProjectsPermissions?.[workspaceSlug]) {
delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][project.id];
}
});
}
projectsResponse.forEach((project) => {
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
});
@@ -188,9 +188,10 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
try {
const workspaceResponse = await this.workspaceService.userWorkspaces();
runInAction(() => {
workspaceResponse.forEach((workspace) => {
set(this.workspaces, [workspace.id], workspace);
});
this.workspaces = workspaceResponse.reduce<Record<string, IWorkspace>>((workspaceMap, workspace) => {
workspaceMap[workspace.id] = workspace;
return workspaceMap;
}, {});
});
return workspaceResponse;
} finally {