ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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

View File

@ -5,6 +5,7 @@
*/
import type { Request } from "express";
import { randomUUID } from "crypto";
import type Redis from "ioredis";
import type { WebSocket as WSSocket } from "ws";
// plane imports
@ -17,7 +18,11 @@ import { ProjectMemberService } from "@/services/project-member.service";
import { UserService } from "@/services/user.service";
const ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project";
const PRESENCE_CHANNEL_PREFIX = "plane:presence:workspace";
const PRESENCE_KEY_PREFIX = "plane:presence:workspace";
const HEARTBEAT_INTERVAL_MS = 25_000;
const PRESENCE_TTL_SECONDS = 70;
const OFFLINE_GRACE_MS = 4_000;
type TIssueRealtimeEvent = {
event_id?: string;
@ -32,6 +37,69 @@ const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
ws.send(JSON.stringify(payload));
};
const presenceChannel = (workspaceSlug: string) => `${PRESENCE_CHANNEL_PREFIX}:${workspaceSlug}`;
const presenceConnectionKey = (workspaceSlug: string, userId: string, connectionId: string) =>
`${PRESENCE_KEY_PREFIX}:${workspaceSlug}:user:${userId}:connection:${connectionId}`;
const presenceConnectionPattern = (workspaceSlug: string, userId?: string) =>
userId
? `${PRESENCE_KEY_PREFIX}:${workspaceSlug}:user:${userId}:connection:*`
: `${PRESENCE_KEY_PREFIX}:${workspaceSlug}:user:*:connection:*`;
const parseUserIdFromPresenceKey = (workspaceSlug: string, key: string) => {
const prefix = `${PRESENCE_KEY_PREFIX}:${workspaceSlug}:user:`;
if (!key.startsWith(prefix)) return undefined;
const userId = key.slice(prefix.length).split(":connection:")[0];
return userId || undefined;
};
const scanPresenceKeys = async (redisClient: Redis, pattern: string) => {
const keys: string[] = [];
let cursor = "0";
do {
const [nextCursor, matchedKeys] = await redisClient.scan(cursor, "MATCH", pattern, "COUNT", 250);
cursor = nextCursor;
keys.push(...matchedKeys);
} while (cursor !== "0");
return keys;
};
const getPresenceConnectionCount = async (redisClient: Redis, workspaceSlug: string, userId: string) =>
(await scanPresenceKeys(redisClient, presenceConnectionPattern(workspaceSlug, userId))).length;
const getOnlineUserIds = async (redisClient: Redis, workspaceSlug: string) => {
const keys = await scanPresenceKeys(redisClient, presenceConnectionPattern(workspaceSlug));
const userIds = new Set<string>();
keys.forEach((key) => {
const userId = parseUserIdFromPresenceKey(workspaceSlug, key);
if (userId) userIds.add(userId);
});
return [...userIds];
};
const publishPresenceEvent = async (
redisClient: Redis,
workspaceSlug: string,
type: "presence.user.online" | "presence.user.offline" | "presence.user.heartbeat",
userId: string
) =>
redisClient.publish(
presenceChannel(workspaceSlug),
JSON.stringify({
event_id: randomUUID(),
type,
workspace_slug: workspaceSlug,
user_id: userId,
server_ts: new Date().toISOString(),
})
);
@Controller("/issues")
export class IssueStreamController {
[key: string]: unknown;
@ -53,8 +121,14 @@ export class IssueStreamController {
let subscriber: Redis | undefined;
let heartbeat: NodeJS.Timeout | undefined;
let presenceKey: string | undefined;
let presenceUserId: string | undefined;
let isCleanedUp = false;
const cleanup = async () => {
if (isCleanedUp) return;
isCleanedUp = true;
if (heartbeat) clearInterval(heartbeat);
if (subscriber) {
@ -65,6 +139,26 @@ export class IssueStreamController {
logger.error("ISSUE_STREAM_CONTROLLER: Redis cleanup failed:", error);
}
}
const redisClient = redisManager.getClient();
if (!redisClient || !presenceKey || !presenceUserId || !workspaceSlug) return;
try {
const currentPresenceUserId = presenceUserId;
const currentPresenceKey = presenceKey;
await redisClient.del(currentPresenceKey);
setTimeout(() => {
void (async () => {
const activeConnections = await getPresenceConnectionCount(redisClient, workspaceSlug, currentPresenceUserId);
if (activeConnections === 0) {
await publishPresenceEvent(redisClient, workspaceSlug, "presence.user.offline", currentPresenceUserId);
}
})();
}, OFFLINE_GRACE_MS);
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: Presence cleanup failed:", error);
}
};
try {
@ -81,13 +175,41 @@ export class IssueStreamController {
}
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
const workspacePresenceChannel = presenceChannel(workspaceSlug);
subscriber = redisClient.duplicate({ lazyConnect: true });
await subscriber.connect();
await subscriber.subscribe(channel);
await subscriber.subscribe(channel, workspacePresenceChannel);
const connectionId = randomUUID();
presenceUserId = user.id;
presenceKey = presenceConnectionKey(workspaceSlug, user.id, connectionId);
const activeConnections = await getPresenceConnectionCount(redisClient, workspaceSlug, user.id);
await redisClient.setex(
presenceKey,
PRESENCE_TTL_SECONDS,
JSON.stringify({
connected_at: new Date().toISOString(),
project_id: projectId,
user_id: user.id,
workspace_slug: workspaceSlug,
})
);
if (activeConnections === 0) {
await publishPresenceEvent(redisClient, workspaceSlug, "presence.user.online", user.id);
}
subscriber.on("message", (_channel, message) => {
try {
const event = JSON.parse(message) as TIssueRealtimeEvent;
if (_channel === workspacePresenceChannel) {
if (!event.type?.startsWith("presence.")) return;
sendJson(ws, event as Record<string, unknown>);
return;
}
if (
event.project_id !== projectId ||
(!event.type?.startsWith("issue.") && !event.type?.startsWith("external_contour."))
@ -106,6 +228,21 @@ export class IssueStreamController {
});
heartbeat = setInterval(() => {
if (presenceKey) {
void redisClient
.setex(
presenceKey,
PRESENCE_TTL_SECONDS,
JSON.stringify({
heartbeat_at: new Date().toISOString(),
project_id: projectId,
user_id: user.id,
workspace_slug: workspaceSlug,
})
)
.then(() => publishPresenceEvent(redisClient, workspaceSlug, "presence.user.heartbeat", user.id))
.catch((error) => logger.error("ISSUE_STREAM_CONTROLLER: Presence heartbeat failed:", error));
}
sendJson(ws, { type: "issue.stream.ping", server_ts: new Date().toISOString() });
}, HEARTBEAT_INTERVAL_MS);
@ -114,6 +251,12 @@ export class IssueStreamController {
project_id: projectId,
user_id: user.id,
});
sendJson(ws, {
type: "presence.snapshot",
workspace_slug: workspaceSlug,
online_user_ids: await getOnlineUserIds(redisClient, workspaceSlug),
server_ts: new Date().toISOString(),
});
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
ws.close(1008, "Issue stream authentication failed");

View File

@ -19,6 +19,7 @@ import { DateDropdown } from "@/components/dropdowns/date";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
import { PresenceDot } from "@/components/presence/presence-dot";
import { useAppRouter } from "@/hooks/use-app-router";
import { useMember } from "@/hooks/store/use-member";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
@ -96,6 +97,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
request.requested_by_name ||
issue.created_by_detail?.display_name ||
"NODE.DC";
const requesterId = request.requested_by?.id || request.requested_by_id || issue.created_by_detail?.id || issue.created_by;
const requesterAvatar = issue.created_by_detail?.avatar_url || "";
const counterpartContourName =
direction === "outgoing"
@ -304,12 +306,19 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
>
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
<div className="absolute top-0.5 left-0.5 z-20">
<div className="relative h-12 w-12">
<Avatar
src={requesterAvatar}
name={requester}
size={48}
className="border border-white/10 shadow-none ring-0 outline-none"
/>
<PresenceDot
workspaceSlug={workspaceSlug}
userId={requesterId}
className={isActive ? "border-[rgb(var(--nodedc-card-active-rgb))]" : undefined}
/>
</div>
</div>
<div className="absolute top-0.5 right-0.5 z-20" onClick={stopCardPropagation}>

View File

@ -203,7 +203,7 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
const renderQuickActions: TRenderQuickActions = useCallback(
({ issue, parentRef, customActionButton }) => (
({ issue, parentRef, customActionButton, menuClassName, menuContentBefore, placement, portalElement }) => (
<QuickActions
parentRef={parentRef}
customActionButton={customActionButton}
@ -213,8 +213,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
portalElement={quickActionsPortalElement}
placements={quickActionsPlacement}
menuClassName={menuClassName}
menuContentBefore={menuContentBefore}
portalElement={portalElement ?? quickActionsPortalElement}
placements={placement ?? quickActionsPlacement}
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
/>
),

View File

@ -5,6 +5,7 @@
*/
import { useMemo } from "react";
import { useParams } from "next/navigation";
import { CalendarDays, MoreHorizontal } from "lucide-react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
@ -15,8 +16,7 @@ import { cn, getFileURL, renderFormattedDate, renderFormattedPayloadDate } from
import { DateDropdown } from "@/components/dropdowns/date";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { PresenceDot } from "@/components/presence/presence-dot";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state";
@ -35,12 +35,13 @@ type Props = {
};
const basePillClasses =
"inline-flex items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors";
"inline-flex min-h-8 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[10px] font-medium shadow-none outline-none transition-colors";
export const InternalContourKanbanCard = observer(function InternalContourKanbanCard(props: Props) {
const { cardRef, issue, updateIssue, quickActions, isReadOnly, isActive } = props;
const { t } = useTranslation();
const { isMobile } = usePlatformOS();
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const { getUserDetails } = useMember();
const { getProjectById } = useProject();
const { getStateById, getProjectStateIds } = useProjectState();
@ -69,20 +70,87 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
]);
const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? t("common.none");
const selectedState = getStateById(issue.state_id);
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const projectStateIds = issue.project_id ? (getProjectStateIds(issue.project_id) ?? []) : [];
const stateOptions = projectStateIds.map((stateId) => getStateById(stateId)).filter((state) => !!state);
const { pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive);
const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color);
const controlStatusIconColor = selectedState?.group === "started" ? "#F5F7FB" : statusIconColor;
const creatorName = creatorDetails?.display_name ?? t("common.none");
const creatorId = creatorDetails?.id || issue.created_by;
const workspaceSlug = routerWorkspaceSlug?.toString();
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const cornerControlClasses =
"flex h-12 w-12 items-center justify-center rounded-full border-0 bg-[#17181B] text-white shadow-none outline-none ring-0 transition-transform hover:scale-[1.03] hover:bg-[#0F1012]";
const cornerControlClasses = cn(
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
isActive
? "border-black/25 text-black hover:bg-black/5"
: "border-white/20 text-white hover:border-white/35 hover:bg-white/5"
);
const menuItemClasses =
"flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors hover:bg-white/6 disabled:cursor-not-allowed disabled:text-placeholder disabled:hover:bg-transparent";
const priorityOptions: NonNullable<TIssue["priority"]>[] = ["urgent", "high", "medium", "low", "none"];
const priorityLabels: Record<NonNullable<TIssue["priority"]>, string> = {
urgent: "Срочный",
high: "Высокий",
medium: "Средний",
low: "Низкий",
none: "Без приоритета",
};
const menuContentBefore = ({ closeDropdown }: { closeDropdown: () => void }) => (
<>
<div className="space-y-1">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Приоритет</div>
{priorityOptions.map((priority) => (
<button
key={priority}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.priority === priority })}
disabled={isReadOnly || !updateIssue}
onClick={() => {
void updateIssue?.(issue.project_id ?? null, issue.id, { priority });
closeDropdown();
}}
>
<PriorityIcon priority={priority} className="h-3.5 w-3.5" />
<span>{priorityLabels[priority]}</span>
</button>
))}
</div>
<div className="space-y-1 border-t border-white/8 pt-2">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">Статус</div>
{stateOptions.map((state) => (
<button
key={state.id}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.state_id === state.id })}
disabled={isReadOnly || !updateIssue}
onClick={() => {
void updateIssue?.(issue.project_id ?? null, issue.id, { state_id: state.id });
closeDropdown();
}}
>
<StateGroupIcon
stateGroup={state.group}
color={getStateGroupColor(state.group, state.color)}
className="h-3.5 w-3.5"
percentage={state.order}
/>
<span>{state.name}</span>
</button>
))}
</div>
<div className="border-t border-white/8 pt-2">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Быстрые действия
</div>
</div>
</>
);
const dateButton = (
<DateDropdown
className="h-auto self-start"
buttonContainerClassName="h-auto w-auto text-left"
buttonVariant="transparent-without-text"
value={issue.target_date}
rangePreview={{
from: issue.start_date,
@ -97,12 +165,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
button={
<div
data-control-link-ignore="true"
className={cn(
"inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-4 font-medium",
pillBackgroundClasses
)}
className={cn(basePillClasses, pillBackgroundClasses)}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<CalendarDays className="h-3 w-3 shrink-0" />
<span className="truncate">{dueDateLabel}</span>
</div>
}
@ -112,71 +177,53 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const customActionButton = (
<div
data-control-link-ignore="true"
className={cn(
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 cursor-pointer items-center justify-center rounded-full bg-black text-white shadow-none ring-0 transition-transform outline-none hover:scale-[1.03]",
isActive
? "text-[rgb(var(--nodedc-card-active-rgb))] hover:bg-black/90"
: "bg-[#111214] text-white hover:bg-[#0A0B0C]"
)}
className={cn(cornerControlClasses, "cursor-pointer")}
>
<MoreHorizontal className="h-4 w-4" />
</div>
);
const header = (
<div className="relative z-10">
<div className="flex min-w-0 items-start gap-2.5 pt-3 pr-[96px] pl-3">
<div className="shrink-0">
<div className="relative z-10 min-h-[54px]">
<div className="absolute top-0.5 left-0.5 z-20">
<div className="relative h-12 w-12">
<Avatar
src={getFileURL(creatorDetails?.avatar_url ?? "")}
name={creatorName}
size="md"
size={48}
showTooltip={!isMobile}
className="border-0 shadow-none ring-0 outline-none"
className="border border-white/10 shadow-none ring-0 outline-none"
/>
<PresenceDot
workspaceSlug={workspaceSlug}
userId={creatorId}
className={isActive ? "border-[rgb(var(--nodedc-card-active-rgb))]" : undefined}
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<div className="truncate text-body-sm-medium leading-5">{sourceContourName}</div>
{dateButton}
</div>
</div>
<div className="absolute top-0.5 right-0.5 flex shrink-0 items-center gap-0">
<PriorityDropdown
value={issue.priority}
onChange={(priority) => updateIssue?.(issue.project_id ?? null, issue.id, { priority })}
disabled={isReadOnly || !updateIssue}
button={
<div data-control-link-ignore="true" className={cornerControlClasses}>
<PriorityIcon priority={issue.priority} className="h-4 w-4" />
<div className="min-w-0 pr-[162px] pl-[58px] pt-1">
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
<div className={cn("truncate text-[10px] leading-3.5 font-medium", isActive ? "text-[#2F4721]" : "text-[#B3B3B8]")}>
{sourceContourName}
</div>
}
/>
<StateDropdown
projectId={issue.project_id ?? undefined}
stateIds={projectStateIds ?? []}
value={issue.state_id}
onChange={(stateId) => updateIssue?.(issue.project_id ?? null, issue.id, { state_id: stateId })}
disabled={isReadOnly || !updateIssue}
button={
<div data-control-link-ignore="true" className={cornerControlClasses}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={controlStatusIconColor}
className="h-4 w-4"
percentage={selectedState?.order}
/>
</div>
}
/>
<div className="absolute top-0.5 right-0.5 z-20" data-control-link-ignore="true">
{quickActions({
issue,
parentRef: cardRef,
customActionButton,
menuClassName: "min-w-[18rem]",
menuContentBefore,
placement: "bottom-end",
})}
</div>
</div>
);
const title = (
<div className="flex flex-col items-center">
<span>{issue.name}</span>
</div>
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
);
const footer = (
@ -187,28 +234,23 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
disabled={isReadOnly || !updateIssue}
multiple
className="h-11 w-11"
buttonContainerClassName="h-11 w-11"
buttonVariant="transparent-without-text"
className="h-7 w-7"
buttonContainerClassName="h-7 w-7"
button={
<div
data-control-link-ignore="true"
className={cn(
basePillClasses,
"h-11 min-w-11 justify-center rounded-full bg-transparent px-0 py-0 shadow-none ring-0 outline-none [&_.bg-accent-subtle]:!bg-transparent [&_.border-subtle-1]:!border-0"
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors [&_.bg-accent-subtle]:!bg-transparent [&_.border-subtle-1]:!border-0",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
)}
>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids} size="sm" />
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids} size={26} />
</div>
}
/>
<div className="flex items-center justify-end">
{quickActions({
issue,
parentRef: cardRef,
customActionButton,
})}
</div>
{dateButton}
</>
);
@ -216,14 +258,14 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
<NodedcWorkItemCard
isActive={isActive}
priority={issue.priority}
surfaceClassName="!rounded-[24px] !p-0"
contentClassName="min-h-[220px]"
surfaceClassName="!rounded-[24px]"
contentClassName="min-h-[220px] px-1"
header={header}
title={title}
titleContainerClassName="px-6 pt-7 pb-14"
titleClassName="line-clamp-none w-full text-[15px] leading-5"
titleContainerClassName="justify-start px-1 pt-7 pb-4 text-left"
titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium"
footer={footer}
footerClassName="pointer-events-auto absolute inset-x-0 bottom-0 h-12 items-end gap-0"
footerClassName="pointer-events-auto items-end gap-3"
/>
);
});

View File

@ -11,6 +11,8 @@ export interface IQuickActionProps {
handleRestore?: () => Promise<void>;
handleMoveToIssues?: () => Promise<void>;
customActionButton?: React.ReactNode;
menuClassName?: string;
menuContentBefore?: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
portalElement?: Element | null;
readOnly?: boolean;
placements?: TPlacement;
@ -20,12 +22,16 @@ export type TRenderQuickActions = ({
issue,
parentRef,
customActionButton,
menuClassName,
menuContentBefore,
placement,
portalElement,
}: {
issue: TIssue;
parentRef: React.RefObject<HTMLElement>;
customActionButton?: React.ReactNode;
menuClassName?: string;
menuContentBefore?: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
placement?: TPlacement;
portalElement?: Element | null;
}) => React.ReactNode;

View File

@ -35,6 +35,8 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
handleUpdate,
handleArchive,
customActionButton,
menuClassName,
menuContentBefore,
portalElement,
readOnly = false,
placements = "bottom-end",
@ -149,7 +151,14 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<ActionDropdown button={customActionButton} items={MENU_ITEMS} placement={placements} portalElement={portalElement} />
<ActionDropdown
button={customActionButton}
items={MENU_ITEMS}
menuClassName={menuClassName}
menuContentBefore={menuContentBefore}
placement={placements}
portalElement={portalElement}
/>
</>
);
});

View File

@ -0,0 +1,25 @@
import { cn } from "@plane/utils";
import { usePresenceStatus } from "@/hooks/use-user-presence";
type Props = {
className?: string;
userId?: string | null;
workspaceSlug?: string | null;
};
export const PresenceDot = (props: Props) => {
const { className, userId, workspaceSlug } = props;
const isOnline = usePresenceStatus(workspaceSlug ?? undefined, userId ?? undefined);
if (!isOnline) return null;
return (
<span
aria-label="Пользователь онлайн"
className={cn(
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-card-passive-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
className
)}
/>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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
);

View File

@ -26,6 +26,7 @@ export interface IActionDropdownProps {
disabled?: boolean;
items: TContextMenuItem[];
menuContent?: TActionDropdownMenuContent;
menuContentBefore?: TActionDropdownMenuContent;
menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void;
placement?: TPlacement;
@ -139,6 +140,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
disabled = false,
items,
menuContent,
menuContentBefore,
menuClassName,
onOpenChange,
placement,
@ -151,6 +153,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
const renderedItems = items.filter((item) => item.shouldRender !== false);
const hasMenuContent = menuContent !== undefined && menuContent !== null;
const hasMenuContentBefore = menuContentBefore !== undefined && menuContentBefore !== null;
const isDropdownDisabled = disabled || (!hasMenuContent && renderedItems.length === 0);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@ -309,6 +312,10 @@ export function ActionDropdown(props: IActionDropdownProps) {
)
) : (
<div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto">
{hasMenuContentBefore &&
(typeof menuContentBefore === "function"
? menuContentBefore({ closeDropdown })
: menuContentBefore)}
{renderedItems.map((item) => (
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
))}