ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime канал карточек задач
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";
|
||||
// plane imports
|
||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
type TIssueRealtimeEvent = {
|
||||
event_id: string;
|
||||
type: "issue.created" | "issue.updated" | "issue.deleted" | "issue.stream.ready" | "issue.stream.ping";
|
||||
workspace_slug?: string;
|
||||
project_id?: string;
|
||||
issue_id?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
type TRealtimeIssueStore = {
|
||||
addIssue?: (issue: TIssue, shouldUpdateList?: boolean) => void;
|
||||
groupedIssueIds?: Record<string, unknown>;
|
||||
removeIssueFromList?: (issueId: string) => void;
|
||||
updateIssueList?: (issue?: TIssue, issueBeforeUpdate?: TIssue) => void;
|
||||
rootIssueStore?: {
|
||||
issues?: {
|
||||
removeIssue?: (issueId: string) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type TIssueFilterSnapshot = {
|
||||
appliedFilters?: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
const REALTIME_STORE_TYPES = new Set<EIssuesStoreType>([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]);
|
||||
const MAX_PROCESSED_EVENTS = 250;
|
||||
|
||||
const hasIssueId = (value: unknown, issueId: string): boolean => {
|
||||
if (Array.isArray(value)) return value.includes(issueId);
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
return Object.values(value).some((nestedValue) => hasIssueId(nestedValue, issueId));
|
||||
};
|
||||
|
||||
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 useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlug?: string, projectId?: string) => {
|
||||
const { issueMap, issues, issuesFilter } = useIssues(storeType);
|
||||
const issueServiceRef = useRef(new IssueService());
|
||||
const issueMapRef = useRef(issueMap);
|
||||
const issuesRef = useRef<TRealtimeIssueStore>(issues as TRealtimeIssueStore);
|
||||
const issueFilterRef = useRef<TIssueFilterSnapshot>(issuesFilter as TIssueFilterSnapshot);
|
||||
const processedEventIdsRef = useRef<string[]>([]);
|
||||
const processedEventSetRef = useRef(new Set<string>());
|
||||
const lastSeenUpdatedAtRef = useRef<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
issueMapRef.current = issueMap;
|
||||
}, [issueMap]);
|
||||
|
||||
useEffect(() => {
|
||||
issuesRef.current = issues as TRealtimeIssueStore;
|
||||
}, [issues]);
|
||||
|
||||
useEffect(() => {
|
||||
issueFilterRef.current = issuesFilter as TIssueFilterSnapshot;
|
||||
}, [issuesFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined") return;
|
||||
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelled = false;
|
||||
let reconnectAttempt = 0;
|
||||
let hasConnectedOnce = false;
|
||||
|
||||
const getFilterParams = () => {
|
||||
const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) };
|
||||
delete filters.cursor;
|
||||
delete filters.group_by;
|
||||
delete filters.per_page;
|
||||
delete filters.sub_group_by;
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
const rememberEvent = (eventId: string) => {
|
||||
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 applyIssue = (issue: TIssue) => {
|
||||
const realtimeStore = issuesRef.current;
|
||||
const issueBeforeUpdate = issueMapRef.current?.[issue.id];
|
||||
|
||||
if (!issueBeforeUpdate || !hasIssueId(realtimeStore.groupedIssueIds, issue.id)) {
|
||||
realtimeStore.addIssue?.(issue, true);
|
||||
return;
|
||||
}
|
||||
|
||||
realtimeStore.addIssue?.(issue, false);
|
||||
realtimeStore.updateIssueList?.(issue, issueBeforeUpdate);
|
||||
};
|
||||
|
||||
const removeIssue = (issueId: string, removeFromMap = false) => {
|
||||
const realtimeStore = issuesRef.current;
|
||||
|
||||
realtimeStore.removeIssueFromList?.(issueId);
|
||||
if (removeFromMap) realtimeStore.rootIssueStore?.issues?.removeIssue?.(issueId);
|
||||
};
|
||||
|
||||
const fetchIssue = async (issueId: string) => {
|
||||
const issues = await issueServiceRef.current.retrieveIssues(workspaceSlug, projectId, [issueId], getFilterParams());
|
||||
|
||||
return issues?.[0];
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (event.type === "issue.deleted") {
|
||||
removeIssue(event.issue_id, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = await fetchIssue(event.issue_id);
|
||||
if (!issue) {
|
||||
removeIssue(event.issue_id);
|
||||
return;
|
||||
}
|
||||
|
||||
applyIssue(issue);
|
||||
};
|
||||
|
||||
const catchUpMissedEvents = async () => {
|
||||
const updatedAt = lastSeenUpdatedAtRef.current;
|
||||
if (!updatedAt) return;
|
||||
|
||||
const response = await issueServiceRef.current.getIssues(workspaceSlug, projectId, {
|
||||
...getFilterParams(),
|
||||
updated_at__gt: updatedAt,
|
||||
per_page: "100",
|
||||
});
|
||||
|
||||
const results = response?.results;
|
||||
if (!Array.isArray(results)) return;
|
||||
|
||||
results.forEach(applyIssue);
|
||||
};
|
||||
|
||||
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;
|
||||
if (hasConnectedOnce) void catchUpMissedEvents();
|
||||
hasConnectedOnce = true;
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
try {
|
||||
const event = JSON.parse(message.data) as TIssueRealtimeEvent;
|
||||
|
||||
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;
|
||||
|
||||
void handleIssueEvent(event);
|
||||
} catch (error) {
|
||||
console.error("Failed to process issue realtime event", error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
socket?.close();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect issue realtime stream", error);
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [storeType, workspaceSlug, projectId]);
|
||||
};
|
||||
Reference in New Issue
Block a user