ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime внешних контуров и действия исходящих карточек
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
|
||||
type TExternalContourRealtimeEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
workspace_slug?: string;
|
||||
project_id?: string;
|
||||
};
|
||||
|
||||
const SYNC_DEBOUNCE_MS = 350;
|
||||
|
||||
const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => {
|
||||
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(/\/$/, "")}/issues/stream`;
|
||||
url.searchParams.set("workspaceSlug", workspaceSlug);
|
||||
url.searchParams.set("projectId", projectId);
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const useExternalContoursRealtimeEvents = (
|
||||
workspaceSlug: string | undefined,
|
||||
projectId: string | undefined,
|
||||
syncBoard: (workspaceSlug: string, projectId: string) => Promise<void>
|
||||
) => {
|
||||
const syncBoardRef = useRef(syncBoard);
|
||||
const processedEventIdsRef = useRef<string[]>([]);
|
||||
const processedEventSetRef = useRef(new Set<string>());
|
||||
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
syncBoardRef.current = syncBoard;
|
||||
}, [syncBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId || 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 > 250) {
|
||||
const removedEventId = processedEventIdsRef.current.shift();
|
||||
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (cancelled) return;
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
|
||||
syncTimerRef.current = setTimeout(() => {
|
||||
void syncBoardRef.current(workspaceSlug, projectId);
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
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(buildIssueStreamUrl(workspaceSlug, projectId));
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectAttempt = 0;
|
||||
scheduleSync();
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
try {
|
||||
const event = JSON.parse(message.data) as TExternalContourRealtimeEvent;
|
||||
|
||||
if (event.type === "issue.stream.ping") {
|
||||
socket?.send(JSON.stringify({ type: "issue.stream.pong" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "issue.stream.ready") return;
|
||||
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||
if (event.project_id && event.project_id !== projectId) return;
|
||||
if (!event.type?.startsWith("external_contour.") && !event.type?.startsWith("issue.")) return;
|
||||
if (!rememberEvent(event.event_id)) return;
|
||||
|
||||
scheduleSync();
|
||||
} catch (error) {
|
||||
console.error("Failed to process external contour realtime event", error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
socket?.close();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect external contour realtime stream", error);
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [workspaceSlug, projectId]);
|
||||
};
|
||||
@@ -41,6 +41,7 @@ type TIssueFilterSnapshot = {
|
||||
|
||||
const REALTIME_STORE_TYPES = new Set<EIssuesStoreType>([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]);
|
||||
const MAX_PROCESSED_EVENTS = 250;
|
||||
const INITIAL_CATCH_UP_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
const hasIssueId = (value: unknown, issueId: string): boolean => {
|
||||
if (Array.isArray(value)) return value.includes(issueId);
|
||||
@@ -91,7 +92,6 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelled = false;
|
||||
let reconnectAttempt = 0;
|
||||
let hasConnectedOnce = false;
|
||||
|
||||
const getFilterParams = () => {
|
||||
const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) };
|
||||
@@ -103,6 +103,32 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
return filters;
|
||||
};
|
||||
|
||||
const rememberUpdatedAt = (updatedAt?: string) => {
|
||||
if (!updatedAt) return;
|
||||
|
||||
const currentUpdatedAt = lastSeenUpdatedAtRef.current;
|
||||
if (!currentUpdatedAt || Date.parse(updatedAt) > Date.parse(currentUpdatedAt)) {
|
||||
lastSeenUpdatedAtRef.current = updatedAt;
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialCatchUpStart = () => {
|
||||
const latestKnownUpdatedAt = Object.values(issueMapRef.current ?? {}).reduce<string | undefined>(
|
||||
(latestUpdatedAt, issue) => {
|
||||
if (!issue?.updated_at) return latestUpdatedAt;
|
||||
if (!latestUpdatedAt || Date.parse(issue.updated_at) > Date.parse(latestUpdatedAt)) return issue.updated_at;
|
||||
|
||||
return latestUpdatedAt;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
const fallbackUpdatedAt = new Date(Date.now() - INITIAL_CATCH_UP_WINDOW_MS).toISOString();
|
||||
if (!latestKnownUpdatedAt) return fallbackUpdatedAt;
|
||||
|
||||
return Date.parse(latestKnownUpdatedAt) < Date.parse(fallbackUpdatedAt) ? latestKnownUpdatedAt : fallbackUpdatedAt;
|
||||
};
|
||||
|
||||
const rememberEvent = (eventId: string) => {
|
||||
if (processedEventSetRef.current.has(eventId)) return false;
|
||||
|
||||
@@ -146,7 +172,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
const handleIssueEvent = async (event: TIssueRealtimeEvent) => {
|
||||
if (!event.event_id || !event.issue_id) return;
|
||||
if (!rememberEvent(event.event_id)) return;
|
||||
if (event.updated_at) lastSeenUpdatedAtRef.current = event.updated_at;
|
||||
rememberUpdatedAt(event.updated_at);
|
||||
|
||||
if (event.type === "issue.deleted") {
|
||||
removeIssue(event.issue_id, true);
|
||||
@@ -175,7 +201,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
const results = response?.results;
|
||||
if (!Array.isArray(results)) return;
|
||||
|
||||
results.forEach(applyIssue);
|
||||
results.forEach((issue) => {
|
||||
rememberUpdatedAt(issue.updated_at);
|
||||
applyIssue(issue);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
@@ -191,8 +220,8 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectAttempt = 0;
|
||||
if (hasConnectedOnce) void catchUpMissedEvents();
|
||||
hasConnectedOnce = true;
|
||||
lastSeenUpdatedAtRef.current = lastSeenUpdatedAtRef.current ?? getInitialCatchUpStart();
|
||||
void catchUpMissedEvents();
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
@@ -205,6 +234,7 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||
}
|
||||
|
||||
if (event.type === "issue.stream.ready") return;
|
||||
if (!event.type?.startsWith("issue.")) return;
|
||||
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||
if (event.project_id && event.project_id !== projectId) return;
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ export class ExternalContourService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRequest(workspaceSlug: string, projectId: string, requestId: string): Promise<void> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`)
|
||||
.then((response) => response?.data)
|
||||
|
||||
+59
@@ -53,7 +53,9 @@ export interface IProjectExternalContoursBoardStore {
|
||||
replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
|
||||
updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
|
||||
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
|
||||
syncBoard: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
upsertBoardItems: (items: TExternalContourRequest[]) => void;
|
||||
removeBoardItem: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore {
|
||||
@@ -101,9 +103,11 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||
fetchBoard: action,
|
||||
handleCurrentTab: action,
|
||||
replaceFilters: action,
|
||||
syncBoard: action,
|
||||
updateFilters: action,
|
||||
updateSorting: action,
|
||||
upsertBoardItems: action,
|
||||
removeBoardItem: action,
|
||||
});
|
||||
|
||||
this.externalContourService = new ExternalContourService();
|
||||
@@ -145,6 +149,19 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||
this.store.projectExternalContours.upsertRequests(items);
|
||||
};
|
||||
|
||||
removeBoardItem = (requestId: string) => {
|
||||
delete this.items[requestId];
|
||||
this.columnIdsMap = {
|
||||
outgoing: this.columnIdsMap.outgoing.filter((id) => id !== requestId),
|
||||
incoming: this.columnIdsMap.incoming.filter((id) => id !== requestId),
|
||||
};
|
||||
this.columnCountMap = {
|
||||
outgoing: this.columnIdsMap.outgoing.length,
|
||||
incoming: this.columnIdsMap.incoming.length,
|
||||
};
|
||||
this.store.projectExternalContours.removeRequest(requestId);
|
||||
};
|
||||
|
||||
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||
this.currentTab = tab;
|
||||
await this.fetchBoard(workspaceSlug, projectId, tab);
|
||||
@@ -248,4 +265,46 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
syncBoard = async (workspaceSlug: string, projectId: string) => {
|
||||
if (this.currentProjectId && this.currentProjectId !== projectId) return;
|
||||
|
||||
const requestId = ++this.lastIssuedRequestId;
|
||||
const nextFilters = sanitizeBoardFilters(this.filters);
|
||||
const nextSorting = this.sorting;
|
||||
|
||||
try {
|
||||
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
|
||||
if (requestId !== this.lastIssuedRequestId) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
|
||||
this.sorting = response.sorting || nextSorting;
|
||||
this.currentProjectId = projectId;
|
||||
this.hydratedProjectId = projectId;
|
||||
let openCount = 0;
|
||||
let closedCount = 0;
|
||||
|
||||
response.columns.forEach((column) => {
|
||||
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
|
||||
this.columnCountMap[column.key] = column.total_count;
|
||||
column.results.forEach((request) => {
|
||||
if (request.status === EInboxIssueCurrentTab.CLOSED) closedCount += 1;
|
||||
else openCount += 1;
|
||||
});
|
||||
this.upsertBoardItems(column.results);
|
||||
});
|
||||
|
||||
this.tabCountMap = {
|
||||
[EInboxIssueCurrentTab.OPEN]: openCount,
|
||||
[EInboxIssueCurrentTab.CLOSED]: closedCount,
|
||||
};
|
||||
this.error = undefined;
|
||||
});
|
||||
} catch {
|
||||
// Realtime sync is best-effort; the next explicit board fetch will surface errors.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface IProjectExternalContoursStore {
|
||||
requestId: string,
|
||||
comment: string
|
||||
) => Promise<TExternalContourRequest | undefined>;
|
||||
deleteRequest: (workspaceSlug: string, projectId: string, requestId: string) => Promise<void>;
|
||||
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
|
||||
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
|
||||
@@ -66,6 +67,7 @@ export interface IProjectExternalContoursStore {
|
||||
closedRequestIds: string[];
|
||||
filteredRequestIds: string[];
|
||||
upsertRequests: (requests: TExternalContourRequest[]) => void;
|
||||
removeRequest: (requestId: string) => void;
|
||||
updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void;
|
||||
}
|
||||
|
||||
@@ -102,9 +104,11 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
||||
fetchRequestById: action,
|
||||
createRequest: action,
|
||||
updateRequest: action,
|
||||
deleteRequest: action,
|
||||
decideRequest: action,
|
||||
replyToRequest: action,
|
||||
handleCurrentTab: action,
|
||||
removeRequest: action,
|
||||
upsertRequests: action,
|
||||
updateRequestIssue: action,
|
||||
});
|
||||
@@ -143,6 +147,11 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
||||
});
|
||||
};
|
||||
|
||||
removeRequest = (requestId: string) => {
|
||||
delete this.requests[requestId];
|
||||
this.requestIds = this.requestIds.filter((id) => id !== requestId);
|
||||
};
|
||||
|
||||
fetchTargetProjects = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId);
|
||||
@@ -269,6 +278,22 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
||||
}
|
||||
};
|
||||
|
||||
deleteRequest = async (workspaceSlug: string, projectId: string, requestId: string) => {
|
||||
this.loader = "mutation-loading";
|
||||
try {
|
||||
await this.externalContourService.deleteRequest(workspaceSlug, projectId, requestId);
|
||||
runInAction(() => {
|
||||
this.removeRequest(requestId);
|
||||
this.loader = undefined;
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
decideRequest = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
||||
Reference in New Issue
Block a user