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