ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: presence автора и унификация карточек
This commit is contained in:
parent
d53fa2b38c
commit
4dbb7b500c
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import type Redis from "ioredis";
|
import type Redis from "ioredis";
|
||||||
import type { WebSocket as WSSocket } from "ws";
|
import type { WebSocket as WSSocket } from "ws";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -17,7 +18,11 @@ import { ProjectMemberService } from "@/services/project-member.service";
|
||||||
import { UserService } from "@/services/user.service";
|
import { UserService } from "@/services/user.service";
|
||||||
|
|
||||||
const ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project";
|
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 HEARTBEAT_INTERVAL_MS = 25_000;
|
||||||
|
const PRESENCE_TTL_SECONDS = 70;
|
||||||
|
const OFFLINE_GRACE_MS = 4_000;
|
||||||
|
|
||||||
type TIssueRealtimeEvent = {
|
type TIssueRealtimeEvent = {
|
||||||
event_id?: string;
|
event_id?: string;
|
||||||
|
|
@ -32,6 +37,69 @@ const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
|
||||||
ws.send(JSON.stringify(payload));
|
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")
|
@Controller("/issues")
|
||||||
export class IssueStreamController {
|
export class IssueStreamController {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
@ -53,8 +121,14 @@ export class IssueStreamController {
|
||||||
|
|
||||||
let subscriber: Redis | undefined;
|
let subscriber: Redis | undefined;
|
||||||
let heartbeat: NodeJS.Timeout | undefined;
|
let heartbeat: NodeJS.Timeout | undefined;
|
||||||
|
let presenceKey: string | undefined;
|
||||||
|
let presenceUserId: string | undefined;
|
||||||
|
let isCleanedUp = false;
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
|
if (isCleanedUp) return;
|
||||||
|
isCleanedUp = true;
|
||||||
|
|
||||||
if (heartbeat) clearInterval(heartbeat);
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
|
||||||
if (subscriber) {
|
if (subscriber) {
|
||||||
|
|
@ -65,6 +139,26 @@ export class IssueStreamController {
|
||||||
logger.error("ISSUE_STREAM_CONTROLLER: Redis cleanup failed:", error);
|
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 {
|
try {
|
||||||
|
|
@ -81,13 +175,41 @@ export class IssueStreamController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
|
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
|
||||||
|
const workspacePresenceChannel = presenceChannel(workspaceSlug);
|
||||||
subscriber = redisClient.duplicate({ lazyConnect: true });
|
subscriber = redisClient.duplicate({ lazyConnect: true });
|
||||||
await subscriber.connect();
|
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) => {
|
subscriber.on("message", (_channel, message) => {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(message) as TIssueRealtimeEvent;
|
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 (
|
if (
|
||||||
event.project_id !== projectId ||
|
event.project_id !== projectId ||
|
||||||
(!event.type?.startsWith("issue.") && !event.type?.startsWith("external_contour."))
|
(!event.type?.startsWith("issue.") && !event.type?.startsWith("external_contour."))
|
||||||
|
|
@ -106,6 +228,21 @@ export class IssueStreamController {
|
||||||
});
|
});
|
||||||
|
|
||||||
heartbeat = setInterval(() => {
|
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() });
|
sendJson(ws, { type: "issue.stream.ping", server_ts: new Date().toISOString() });
|
||||||
}, HEARTBEAT_INTERVAL_MS);
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
|
@ -114,6 +251,12 @@ export class IssueStreamController {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
user_id: user.id,
|
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) {
|
} catch (error) {
|
||||||
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
||||||
ws.close(1008, "Issue stream authentication failed");
|
ws.close(1008, "Issue stream authentication failed");
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { DateDropdown } from "@/components/dropdowns/date";
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||||
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
|
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
|
||||||
|
import { PresenceDot } from "@/components/presence/presence-dot";
|
||||||
import { useAppRouter } from "@/hooks/use-app-router";
|
import { useAppRouter } from "@/hooks/use-app-router";
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||||
|
|
@ -96,6 +97,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
request.requested_by_name ||
|
request.requested_by_name ||
|
||||||
issue.created_by_detail?.display_name ||
|
issue.created_by_detail?.display_name ||
|
||||||
"NODE.DC";
|
"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 requesterAvatar = issue.created_by_detail?.avatar_url || "";
|
||||||
const counterpartContourName =
|
const counterpartContourName =
|
||||||
direction === "outgoing"
|
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={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
|
||||||
<div className="absolute top-0.5 left-0.5 z-20">
|
<div className="absolute top-0.5 left-0.5 z-20">
|
||||||
<Avatar
|
<div className="relative h-12 w-12">
|
||||||
src={requesterAvatar}
|
<Avatar
|
||||||
name={requester}
|
src={requesterAvatar}
|
||||||
size={48}
|
name={requester}
|
||||||
className="border border-white/10 shadow-none ring-0 outline-none"
|
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>
|
||||||
|
|
||||||
<div className="absolute top-0.5 right-0.5 z-20" onClick={stopCardPropagation}>
|
<div className="absolute top-0.5 right-0.5 z-20" onClick={stopCardPropagation}>
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
|
||||||
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
|
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
|
||||||
|
|
||||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||||
({ issue, parentRef, customActionButton }) => (
|
({ issue, parentRef, customActionButton, menuClassName, menuContentBefore, placement, portalElement }) => (
|
||||||
<QuickActions
|
<QuickActions
|
||||||
parentRef={parentRef}
|
parentRef={parentRef}
|
||||||
customActionButton={customActionButton}
|
customActionButton={customActionButton}
|
||||||
|
|
@ -213,8 +213,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
|
||||||
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
|
handleRemoveFromView={async () => removeIssueFromView && removeIssueFromView(issue.project_id, issue.id)}
|
||||||
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
|
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
|
||||||
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
|
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
|
||||||
portalElement={quickActionsPortalElement}
|
menuClassName={menuClassName}
|
||||||
placements={quickActionsPlacement}
|
menuContentBefore={menuContentBefore}
|
||||||
|
portalElement={portalElement ?? quickActionsPortalElement}
|
||||||
|
placements={placement ?? quickActionsPlacement}
|
||||||
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
|
readOnly={!canEditProperties(issue.project_id ?? undefined) || isCompletedCycle}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import { CalendarDays, MoreHorizontal } from "lucide-react";
|
import { CalendarDays, MoreHorizontal } from "lucide-react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
|
@ -15,8 +16,7 @@ import { cn, getFileURL, renderFormattedDate, renderFormattedPayloadDate } from
|
||||||
import { DateDropdown } from "@/components/dropdowns/date";
|
import { DateDropdown } from "@/components/dropdowns/date";
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
import { PresenceDot } from "@/components/presence/presence-dot";
|
||||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||||
|
|
@ -35,12 +35,13 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const basePillClasses =
|
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) {
|
export const InternalContourKanbanCard = observer(function InternalContourKanbanCard(props: Props) {
|
||||||
const { cardRef, issue, updateIssue, quickActions, isReadOnly, isActive } = props;
|
const { cardRef, issue, updateIssue, quickActions, isReadOnly, isActive } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
|
const { workspaceSlug: routerWorkspaceSlug } = useParams();
|
||||||
const { getUserDetails } = useMember();
|
const { getUserDetails } = useMember();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
const { getStateById, getProjectStateIds } = useProjectState();
|
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 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 { 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 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 dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||||
const cornerControlClasses =
|
const cornerControlClasses = cn(
|
||||||
"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]";
|
"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 = (
|
const dateButton = (
|
||||||
<DateDropdown
|
<DateDropdown
|
||||||
className="h-auto self-start"
|
className="h-auto self-start"
|
||||||
buttonContainerClassName="h-auto w-auto text-left"
|
buttonContainerClassName="h-auto w-auto text-left"
|
||||||
|
buttonVariant="transparent-without-text"
|
||||||
value={issue.target_date}
|
value={issue.target_date}
|
||||||
rangePreview={{
|
rangePreview={{
|
||||||
from: issue.start_date,
|
from: issue.start_date,
|
||||||
|
|
@ -97,12 +165,9 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
button={
|
button={
|
||||||
<div
|
<div
|
||||||
data-control-link-ignore="true"
|
data-control-link-ignore="true"
|
||||||
className={cn(
|
className={cn(basePillClasses, pillBackgroundClasses)}
|
||||||
"inline-flex max-w-full items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-4 font-medium",
|
|
||||||
pillBackgroundClasses
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
|
<CalendarDays className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">{dueDateLabel}</span>
|
<span className="truncate">{dueDateLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -112,71 +177,53 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
const customActionButton = (
|
const customActionButton = (
|
||||||
<div
|
<div
|
||||||
data-control-link-ignore="true"
|
data-control-link-ignore="true"
|
||||||
className={cn(
|
className={cn(cornerControlClasses, "cursor-pointer")}
|
||||||
"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]"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className="relative z-10">
|
<div className="relative z-10 min-h-[54px]">
|
||||||
<div className="flex min-w-0 items-start gap-2.5 pt-3 pr-[96px] pl-3">
|
<div className="absolute top-0.5 left-0.5 z-20">
|
||||||
<div className="shrink-0">
|
<div className="relative h-12 w-12">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={getFileURL(creatorDetails?.avatar_url ?? "")}
|
src={getFileURL(creatorDetails?.avatar_url ?? "")}
|
||||||
name={creatorName}
|
name={creatorName}
|
||||||
size="md"
|
size={48}
|
||||||
showTooltip={!isMobile}
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="absolute top-0.5 right-0.5 flex shrink-0 items-center gap-0">
|
<div className="min-w-0 pr-[162px] pl-[58px] pt-1">
|
||||||
<PriorityDropdown
|
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
|
||||||
value={issue.priority}
|
<div className={cn("truncate text-[10px] leading-3.5 font-medium", isActive ? "text-[#2F4721]" : "text-[#B3B3B8]")}>
|
||||||
onChange={(priority) => updateIssue?.(issue.project_id ?? null, issue.id, { priority })}
|
{sourceContourName}
|
||||||
disabled={isReadOnly || !updateIssue}
|
</div>
|
||||||
button={
|
</div>
|
||||||
<div data-control-link-ignore="true" className={cornerControlClasses}>
|
|
||||||
<PriorityIcon priority={issue.priority} className="h-4 w-4" />
|
<div className="absolute top-0.5 right-0.5 z-20" data-control-link-ignore="true">
|
||||||
</div>
|
{quickActions({
|
||||||
}
|
issue,
|
||||||
/>
|
parentRef: cardRef,
|
||||||
<StateDropdown
|
customActionButton,
|
||||||
projectId={issue.project_id ?? undefined}
|
menuClassName: "min-w-[18rem]",
|
||||||
stateIds={projectStateIds ?? []}
|
menuContentBefore,
|
||||||
value={issue.state_id}
|
placement: "bottom-end",
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = (
|
const title = (
|
||||||
<div className="flex flex-col items-center">
|
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
|
||||||
<span>{issue.name}</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
|
|
@ -187,28 +234,23 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
|
onChange={(assigneeIds) => updateIssue?.(issue.project_id ?? null, issue.id, { assignee_ids: assigneeIds })}
|
||||||
disabled={isReadOnly || !updateIssue}
|
disabled={isReadOnly || !updateIssue}
|
||||||
multiple
|
multiple
|
||||||
className="h-11 w-11"
|
buttonVariant="transparent-without-text"
|
||||||
buttonContainerClassName="h-11 w-11"
|
className="h-7 w-7"
|
||||||
|
buttonContainerClassName="h-7 w-7"
|
||||||
button={
|
button={
|
||||||
<div
|
<div
|
||||||
data-control-link-ignore="true"
|
data-control-link-ignore="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
basePillClasses,
|
"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",
|
||||||
"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"
|
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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
{dateButton}
|
||||||
{quickActions({
|
|
||||||
issue,
|
|
||||||
parentRef: cardRef,
|
|
||||||
customActionButton,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -216,14 +258,14 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||||
<NodedcWorkItemCard
|
<NodedcWorkItemCard
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
priority={issue.priority}
|
priority={issue.priority}
|
||||||
surfaceClassName="!rounded-[24px] !p-0"
|
surfaceClassName="!rounded-[24px]"
|
||||||
contentClassName="min-h-[220px]"
|
contentClassName="min-h-[220px] px-1"
|
||||||
header={header}
|
header={header}
|
||||||
title={title}
|
title={title}
|
||||||
titleContainerClassName="px-6 pt-7 pb-14"
|
titleContainerClassName="justify-start px-1 pt-7 pb-4 text-left"
|
||||||
titleClassName="line-clamp-none w-full text-[15px] leading-5"
|
titleClassName="line-clamp-none w-full text-[15px] leading-5 font-medium"
|
||||||
footer={footer}
|
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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export interface IQuickActionProps {
|
||||||
handleRestore?: () => Promise<void>;
|
handleRestore?: () => Promise<void>;
|
||||||
handleMoveToIssues?: () => Promise<void>;
|
handleMoveToIssues?: () => Promise<void>;
|
||||||
customActionButton?: React.ReactNode;
|
customActionButton?: React.ReactNode;
|
||||||
|
menuClassName?: string;
|
||||||
|
menuContentBefore?: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
|
||||||
portalElement?: Element | null;
|
portalElement?: Element | null;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
placements?: TPlacement;
|
placements?: TPlacement;
|
||||||
|
|
@ -20,12 +22,16 @@ export type TRenderQuickActions = ({
|
||||||
issue,
|
issue,
|
||||||
parentRef,
|
parentRef,
|
||||||
customActionButton,
|
customActionButton,
|
||||||
|
menuClassName,
|
||||||
|
menuContentBefore,
|
||||||
placement,
|
placement,
|
||||||
portalElement,
|
portalElement,
|
||||||
}: {
|
}: {
|
||||||
issue: TIssue;
|
issue: TIssue;
|
||||||
parentRef: React.RefObject<HTMLElement>;
|
parentRef: React.RefObject<HTMLElement>;
|
||||||
customActionButton?: React.ReactNode;
|
customActionButton?: React.ReactNode;
|
||||||
|
menuClassName?: string;
|
||||||
|
menuContentBefore?: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
|
||||||
placement?: TPlacement;
|
placement?: TPlacement;
|
||||||
portalElement?: Element | null;
|
portalElement?: Element | null;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
|
||||||
handleUpdate,
|
handleUpdate,
|
||||||
handleArchive,
|
handleArchive,
|
||||||
customActionButton,
|
customActionButton,
|
||||||
|
menuClassName,
|
||||||
|
menuContentBefore,
|
||||||
portalElement,
|
portalElement,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
placements = "bottom-end",
|
placements = "bottom-end",
|
||||||
|
|
@ -149,7 +151,14 @@ export const ProjectIssueQuickActions = observer(function ProjectIssueQuickActio
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
|
<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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -6,12 +6,15 @@
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||||
|
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
|
||||||
|
|
||||||
type TExternalContourRealtimeEvent = {
|
type TExternalContourRealtimeEvent = {
|
||||||
event_id?: string;
|
event_id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
workspace_slug?: string;
|
workspace_slug?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
online_user_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYNC_DEBOUNCE_MS = 350;
|
const SYNC_DEBOUNCE_MS = 350;
|
||||||
|
|
@ -101,6 +104,10 @@ export const useExternalContoursRealtimeEvents = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "issue.stream.ready") return;
|
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.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||||
if (event.project_id && event.project_id !== projectId) return;
|
if (event.project_id && event.project_id !== projectId) return;
|
||||||
if (!event.type?.startsWith("external_contour.") && !event.type?.startsWith("issue.")) 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";
|
import { EIssuesStoreType } from "@plane/types";
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssues } from "@/hooks/store/use-issues";
|
import { useIssues } from "@/hooks/store/use-issues";
|
||||||
|
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
|
||||||
// services
|
// services
|
||||||
import { IssueService } from "@/services/issue";
|
import { IssueService } from "@/services/issue";
|
||||||
|
|
||||||
type TIssueRealtimeEvent = {
|
type TIssueRealtimeEvent = {
|
||||||
event_id: string;
|
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;
|
workspace_slug?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
issue_id?: string;
|
issue_id?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
user_id?: string;
|
||||||
|
online_user_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TRealtimeIssueStore = {
|
type TRealtimeIssueStore = {
|
||||||
|
|
@ -234,6 +246,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "issue.stream.ready") return;
|
if (event.type === "issue.stream.ready") return;
|
||||||
|
if (event.type?.startsWith("presence.")) {
|
||||||
|
applyPresenceRealtimeEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!event.type?.startsWith("issue.")) return;
|
if (!event.type?.startsWith("issue.")) return;
|
||||||
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||||
if (event.project_id && event.project_id !== projectId) 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
|
||||||
|
);
|
||||||
|
|
@ -26,6 +26,7 @@ export interface IActionDropdownProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
items: TContextMenuItem[];
|
items: TContextMenuItem[];
|
||||||
menuContent?: TActionDropdownMenuContent;
|
menuContent?: TActionDropdownMenuContent;
|
||||||
|
menuContentBefore?: TActionDropdownMenuContent;
|
||||||
menuClassName?: string;
|
menuClassName?: string;
|
||||||
onOpenChange?: (isOpen: boolean) => void;
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
placement?: TPlacement;
|
placement?: TPlacement;
|
||||||
|
|
@ -139,6 +140,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
|
||||||
disabled = false,
|
disabled = false,
|
||||||
items,
|
items,
|
||||||
menuContent,
|
menuContent,
|
||||||
|
menuContentBefore,
|
||||||
menuClassName,
|
menuClassName,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
placement,
|
placement,
|
||||||
|
|
@ -151,6 +153,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
|
||||||
|
|
||||||
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
const renderedItems = items.filter((item) => item.shouldRender !== false);
|
||||||
const hasMenuContent = menuContent !== undefined && menuContent !== null;
|
const hasMenuContent = menuContent !== undefined && menuContent !== null;
|
||||||
|
const hasMenuContentBefore = menuContentBefore !== undefined && menuContentBefore !== null;
|
||||||
const isDropdownDisabled = disabled || (!hasMenuContent && renderedItems.length === 0);
|
const isDropdownDisabled = disabled || (!hasMenuContent && renderedItems.length === 0);
|
||||||
|
|
||||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
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">
|
<div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto">
|
||||||
|
{hasMenuContentBefore &&
|
||||||
|
(typeof menuContentBefore === "function"
|
||||||
|
? menuContentBefore({ closeDropdown })
|
||||||
|
: menuContentBefore)}
|
||||||
{renderedItems.map((item) => (
|
{renderedItems.map((item) => (
|
||||||
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
|
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue