ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: presence автора и унификация карточек

This commit is contained in:
DCCONSTRUCTIONS
2026-04-29 14:22:42 +03:00
parent d53fa2b38c
commit 4dbb7b500c
11 changed files with 466 additions and 89 deletions
@@ -6,12 +6,15 @@
import { useEffect, useRef } from "react";
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
type TExternalContourRealtimeEvent = {
event_id?: string;
type?: string;
workspace_slug?: string;
project_id?: string;
user_id?: string;
online_user_ids?: string[];
};
const SYNC_DEBOUNCE_MS = 350;
@@ -101,6 +104,10 @@ export const useExternalContoursRealtimeEvents = (
}
if (event.type === "issue.stream.ready") return;
if (event.type?.startsWith("presence.")) {
applyPresenceRealtimeEvent(event);
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;
@@ -11,16 +11,28 @@ import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
// services
import { IssueService } from "@/services/issue";
type TIssueRealtimeEvent = {
event_id: string;
type: "issue.created" | "issue.updated" | "issue.deleted" | "issue.stream.ready" | "issue.stream.ping";
type:
| "issue.created"
| "issue.updated"
| "issue.deleted"
| "issue.stream.ready"
| "issue.stream.ping"
| "presence.snapshot"
| "presence.user.online"
| "presence.user.offline"
| "presence.user.heartbeat";
workspace_slug?: string;
project_id?: string;
issue_id?: string;
updated_at?: string;
user_id?: string;
online_user_ids?: string[];
};
type TRealtimeIssueStore = {
@@ -234,6 +246,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
}
if (event.type === "issue.stream.ready") return;
if (event.type?.startsWith("presence.")) {
applyPresenceRealtimeEvent(event);
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;
@@ -0,0 +1,111 @@
import { useSyncExternalStore } from "react";
type TPresenceRealtimeEvent = {
type?: string;
workspace_slug?: string;
user_id?: string;
online_user_ids?: string[];
};
const PRESENCE_STALE_MS = 70_000;
const PRUNE_INTERVAL_MS = 10_000;
const presenceByWorkspace = new Map<string, Map<string, number>>();
const listeners = new Set<() => void>();
let pruneTimer: ReturnType<typeof setInterval> | undefined;
const getExpiry = () => Date.now() + PRESENCE_STALE_MS;
const notify = () => {
listeners.forEach((listener) => listener());
};
const pruneExpiredPresence = () => {
const now = Date.now();
let hasChanges = false;
presenceByWorkspace.forEach((workspacePresence, workspaceSlug) => {
workspacePresence.forEach((expiresAt, userId) => {
if (expiresAt > now) return;
workspacePresence.delete(userId);
hasChanges = true;
});
if (workspacePresence.size === 0) presenceByWorkspace.delete(workspaceSlug);
});
if (hasChanges) notify();
};
const subscribePresence = (listener: () => void) => {
listeners.add(listener);
if (!pruneTimer) {
pruneTimer = setInterval(pruneExpiredPresence, PRUNE_INTERVAL_MS);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && pruneTimer) {
clearInterval(pruneTimer);
pruneTimer = undefined;
}
};
};
const getWorkspacePresence = (workspaceSlug: string) => {
const existingPresence = presenceByWorkspace.get(workspaceSlug);
if (existingPresence) return existingPresence;
const nextPresence = new Map<string, number>();
presenceByWorkspace.set(workspaceSlug, nextPresence);
return nextPresence;
};
const getPresenceSnapshot = (workspaceSlug?: string, userId?: string) => {
if (!workspaceSlug || !userId) return false;
const workspacePresence = presenceByWorkspace.get(workspaceSlug);
const expiresAt = workspacePresence?.get(userId);
return !!expiresAt && expiresAt > Date.now();
};
export const applyPresenceRealtimeEvent = (event: TPresenceRealtimeEvent) => {
if (!event.type?.startsWith("presence.") || !event.workspace_slug) return;
if (event.type === "presence.snapshot") {
const workspacePresence = new Map<string, number>();
const expiresAt = getExpiry();
event.online_user_ids?.forEach((userId) => {
if (userId) workspacePresence.set(userId, expiresAt);
});
presenceByWorkspace.set(event.workspace_slug, workspacePresence);
notify();
return;
}
if (!event.user_id) return;
const workspacePresence = getWorkspacePresence(event.workspace_slug);
if (event.type === "presence.user.offline") {
workspacePresence.delete(event.user_id);
} else {
workspacePresence.set(event.user_id, getExpiry());
}
notify();
};
export const usePresenceStatus = (workspaceSlug?: string, userId?: string) =>
useSyncExternalStore(
subscribePresence,
() => getPresenceSnapshot(workspaceSlug, userId),
() => false
);