From 4dbb7b500c7d1661f0e2074c3cae6a4e5edacba2 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 29 Apr 2026 14:22:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20presence=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B8=20=D1=83=D0=BD=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/issue-stream.controller.ts | 145 ++++++++++++- .../projects/external-contours/board-item.tsx | 21 +- .../issue-layouts/kanban/base-kanban-root.tsx | 8 +- .../kanban/internal-contour-card.tsx | 196 +++++++++++------- .../issue-layouts/list/list-view-types.d.ts | 6 + .../quick-action-dropdowns/project-issue.tsx | 11 +- .../core/components/presence/presence-dot.tsx | 25 +++ .../use-external-contours-realtime-events.ts | 7 + .../core/hooks/use-issue-realtime-events.ts | 18 +- .../apps/web/core/hooks/use-user-presence.ts | 111 ++++++++++ .../ui/src/dropdowns/action-dropdown.tsx | 7 + 11 files changed, 466 insertions(+), 89 deletions(-) create mode 100644 plane-src/apps/web/core/components/presence/presence-dot.tsx create mode 100644 plane-src/apps/web/core/hooks/use-user-presence.ts diff --git a/plane-src/apps/live/src/controllers/issue-stream.controller.ts b/plane-src/apps/live/src/controllers/issue-stream.controller.ts index bab122b..498b852 100644 --- a/plane-src/apps/live/src/controllers/issue-stream.controller.ts +++ b/plane-src/apps/live/src/controllers/issue-stream.controller.ts @@ -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) => { 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(); + + 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); + 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"); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index 35eb3fe..5a2debc 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -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 >
- +
+ + +
diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx index ac3c26e..3871c3f 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -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 }) => ( 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} /> ), diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx index 3ff9780..d26d089 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/internal-contour-card.tsx @@ -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[] = ["urgent", "high", "medium", "low", "none"]; + const priorityLabels: Record, string> = { + urgent: "Срочный", + high: "Высокий", + medium: "Средний", + low: "Низкий", + none: "Без приоритета", + }; + const menuContentBefore = ({ closeDropdown }: { closeDropdown: () => void }) => ( + <> +
+
Приоритет
+ {priorityOptions.map((priority) => ( + + ))} +
+ +
+
Статус
+ {stateOptions.map((state) => ( + + ))} +
+ +
+
+ Быстрые действия +
+
+ + ); const dateButton = ( - + {dueDateLabel}
} @@ -112,71 +177,53 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban const customActionButton = (
); const header = ( -
-
-
+
+
+
+ -
-
-
{sourceContourName}
- {dateButton}
-
- updateIssue?.(issue.project_id ?? null, issue.id, { priority })} - disabled={isReadOnly || !updateIssue} - button={ -
- -
- } - /> - updateIssue?.(issue.project_id ?? null, issue.id, { state_id: stateId })} - disabled={isReadOnly || !updateIssue} - button={ -
- -
- } - /> +
+
{creatorName}
+
+ {sourceContourName} +
+
+ +
+ {quickActions({ + issue, + parentRef: cardRef, + customActionButton, + menuClassName: "min-w-[18rem]", + menuContentBefore, + placement: "bottom-end", + })}
); const title = ( -
- {issue.name} -
+
{issue.name}
); 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={
- +
} /> -
- {quickActions({ - issue, - parentRef: cardRef, - customActionButton, - })} -
+ {dateButton} ); @@ -216,14 +258,14 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban ); }); diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts b/plane-src/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts index 7f5164e..34294be 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/plane-src/apps/web/core/components/issues/issue-layouts/list/list-view-types.d.ts @@ -11,6 +11,8 @@ export interface IQuickActionProps { handleRestore?: () => Promise; handleMoveToIssues?: () => Promise; 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; customActionButton?: React.ReactNode; + menuClassName?: string; + menuContentBefore?: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode); placement?: TPlacement; portalElement?: Element | null; }) => React.ReactNode; diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index aec1004..7b4dbbb 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -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 )} - + ); }); diff --git a/plane-src/apps/web/core/components/presence/presence-dot.tsx b/plane-src/apps/web/core/components/presence/presence-dot.tsx new file mode 100644 index 0000000..2099504 --- /dev/null +++ b/plane-src/apps/web/core/components/presence/presence-dot.tsx @@ -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 ( + + ); +}; diff --git a/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts b/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts index 05c6e4e..b309cc8 100644 --- a/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts +++ b/plane-src/apps/web/core/hooks/use-external-contours-realtime-events.ts @@ -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; diff --git a/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts b/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts index 2f5c1d4..b5d798a 100644 --- a/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts +++ b/plane-src/apps/web/core/hooks/use-issue-realtime-events.ts @@ -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; diff --git a/plane-src/apps/web/core/hooks/use-user-presence.ts b/plane-src/apps/web/core/hooks/use-user-presence.ts new file mode 100644 index 0000000..518496a --- /dev/null +++ b/plane-src/apps/web/core/hooks/use-user-presence.ts @@ -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>(); +const listeners = new Set<() => void>(); + +let pruneTimer: ReturnType | 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(); + 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(); + 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 + ); diff --git a/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx b/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx index a1449ef..680c942 100644 --- a/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx +++ b/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx @@ -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) { ) ) : (
+ {hasMenuContentBefore && + (typeof menuContentBefore === "function" + ? menuContentBefore({ closeDropdown }) + : menuContentBefore)} {renderedItems.map((item) => ( ))}