ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: presence автора и унификация карточек
This commit is contained in:
parent
d53fa2b38c
commit
4dbb7b500c
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<Avatar
|
||||
src={requesterAvatar}
|
||||
name={requester}
|
||||
size={48}
|
||||
className="border border-white/10 shadow-none ring-0 outline-none"
|
||||
/>
|
||||
<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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
<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="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>
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
|
||||
|
||||
type TExternalContourRealtimeEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
workspace_slug?: string;
|
||||
project_id?: string;
|
||||
user_id?: string;
|
||||
online_user_ids?: string[];
|
||||
};
|
||||
|
||||
const SYNC_DEBOUNCE_MS = 350;
|
||||
|
|
@ -101,6 +104,10 @@ export const useExternalContoursRealtimeEvents = (
|
|||
}
|
||||
|
||||
if (event.type === "issue.stream.ready") return;
|
||||
if (event.type?.startsWith("presence.")) {
|
||||
applyPresenceRealtimeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||
if (event.project_id && event.project_id !== projectId) return;
|
||||
if (!event.type?.startsWith("external_contour.") && !event.type?.startsWith("issue.")) return;
|
||||
|
|
|
|||
|
|
@ -11,16 +11,28 @@ import type { TIssue } from "@plane/types";
|
|||
import { EIssuesStoreType } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { applyPresenceRealtimeEvent } from "@/hooks/use-user-presence";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
type TIssueRealtimeEvent = {
|
||||
event_id: string;
|
||||
type: "issue.created" | "issue.updated" | "issue.deleted" | "issue.stream.ready" | "issue.stream.ping";
|
||||
type:
|
||||
| "issue.created"
|
||||
| "issue.updated"
|
||||
| "issue.deleted"
|
||||
| "issue.stream.ready"
|
||||
| "issue.stream.ping"
|
||||
| "presence.snapshot"
|
||||
| "presence.user.online"
|
||||
| "presence.user.offline"
|
||||
| "presence.user.heartbeat";
|
||||
workspace_slug?: string;
|
||||
project_id?: string;
|
||||
issue_id?: string;
|
||||
updated_at?: string;
|
||||
user_id?: string;
|
||||
online_user_ids?: string[];
|
||||
};
|
||||
|
||||
type TRealtimeIssueStore = {
|
||||
|
|
@ -234,6 +246,10 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
|
|||
}
|
||||
|
||||
if (event.type === "issue.stream.ready") return;
|
||||
if (event.type?.startsWith("presence.")) {
|
||||
applyPresenceRealtimeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (!event.type?.startsWith("issue.")) return;
|
||||
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
|
||||
if (event.project_id && event.project_id !== projectId) return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
type TPresenceRealtimeEvent = {
|
||||
type?: string;
|
||||
workspace_slug?: string;
|
||||
user_id?: string;
|
||||
online_user_ids?: string[];
|
||||
};
|
||||
|
||||
const PRESENCE_STALE_MS = 70_000;
|
||||
const PRUNE_INTERVAL_MS = 10_000;
|
||||
|
||||
const presenceByWorkspace = new Map<string, Map<string, number>>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let pruneTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const getExpiry = () => Date.now() + PRESENCE_STALE_MS;
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const pruneExpiredPresence = () => {
|
||||
const now = Date.now();
|
||||
let hasChanges = false;
|
||||
|
||||
presenceByWorkspace.forEach((workspacePresence, workspaceSlug) => {
|
||||
workspacePresence.forEach((expiresAt, userId) => {
|
||||
if (expiresAt > now) return;
|
||||
workspacePresence.delete(userId);
|
||||
hasChanges = true;
|
||||
});
|
||||
|
||||
if (workspacePresence.size === 0) presenceByWorkspace.delete(workspaceSlug);
|
||||
});
|
||||
|
||||
if (hasChanges) notify();
|
||||
};
|
||||
|
||||
const subscribePresence = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
|
||||
if (!pruneTimer) {
|
||||
pruneTimer = setInterval(pruneExpiredPresence, PRUNE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
|
||||
if (listeners.size === 0 && pruneTimer) {
|
||||
clearInterval(pruneTimer);
|
||||
pruneTimer = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getWorkspacePresence = (workspaceSlug: string) => {
|
||||
const existingPresence = presenceByWorkspace.get(workspaceSlug);
|
||||
if (existingPresence) return existingPresence;
|
||||
|
||||
const nextPresence = new Map<string, number>();
|
||||
presenceByWorkspace.set(workspaceSlug, nextPresence);
|
||||
|
||||
return nextPresence;
|
||||
};
|
||||
|
||||
const getPresenceSnapshot = (workspaceSlug?: string, userId?: string) => {
|
||||
if (!workspaceSlug || !userId) return false;
|
||||
|
||||
const workspacePresence = presenceByWorkspace.get(workspaceSlug);
|
||||
const expiresAt = workspacePresence?.get(userId);
|
||||
|
||||
return !!expiresAt && expiresAt > Date.now();
|
||||
};
|
||||
|
||||
export const applyPresenceRealtimeEvent = (event: TPresenceRealtimeEvent) => {
|
||||
if (!event.type?.startsWith("presence.") || !event.workspace_slug) return;
|
||||
|
||||
if (event.type === "presence.snapshot") {
|
||||
const workspacePresence = new Map<string, number>();
|
||||
const expiresAt = getExpiry();
|
||||
|
||||
event.online_user_ids?.forEach((userId) => {
|
||||
if (userId) workspacePresence.set(userId, expiresAt);
|
||||
});
|
||||
|
||||
presenceByWorkspace.set(event.workspace_slug, workspacePresence);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.user_id) return;
|
||||
|
||||
const workspacePresence = getWorkspacePresence(event.workspace_slug);
|
||||
|
||||
if (event.type === "presence.user.offline") {
|
||||
workspacePresence.delete(event.user_id);
|
||||
} else {
|
||||
workspacePresence.set(event.user_id, getExpiry());
|
||||
}
|
||||
|
||||
notify();
|
||||
};
|
||||
|
||||
export const usePresenceStatus = (workspaceSlug?: string, userId?: string) =>
|
||||
useSyncExternalStore(
|
||||
subscribePresence,
|
||||
() => getPresenceSnapshot(workspaceSlug, userId),
|
||||
() => false
|
||||
);
|
||||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue