UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректный flow уведомлений Tasker
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { InboxIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { buildNodeDCBrandConfigUrl, buildNodeDCLauncherUrl } from "@/helpers/nodedc-auth";
|
||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
|
||||
type TNodeDCStandaloneShellProps = {
|
||||
children: ReactNode;
|
||||
notificationsCount?: number;
|
||||
onOpenNotifications?: () => void;
|
||||
showUserControls?: boolean;
|
||||
};
|
||||
|
||||
export const NodeDCStandaloneShell = (props: TNodeDCStandaloneShellProps) => {
|
||||
const { children, notificationsCount = 0, onOpenNotifications, showUserControls = false } = props;
|
||||
const { t } = useTranslation();
|
||||
const [logoLinkUrl, setLogoLinkUrl] = useState(buildNodeDCLauncherUrl);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
fetch(buildNodeDCBrandConfigUrl(), { cache: "no-store" })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload: { logoLinkUrl?: string } | null) => {
|
||||
if (isMounted && payload?.logoLinkUrl) setLogoLinkUrl(payload.logoLinkUrl);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить brand config NODE.DC");
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full overflow-hidden bg-[#050507] text-primary">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div className="absolute top-[-18rem] left-[-12rem] h-[34rem] w-[34rem] rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-[120px]" />
|
||||
<div className="absolute right-[-14rem] bottom-[-18rem] h-[38rem] w-[38rem] rounded-full bg-white/7 blur-[140px]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,255,255,0.06),transparent_38%),linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0))]" />
|
||||
</div>
|
||||
|
||||
<header className="nodedc-expanded-toolbar-shell absolute inset-x-0 top-0 z-[2]">
|
||||
<div className="nodedc-expanded-toolbar-top">
|
||||
<div className="nodedc-expanded-toolbar-left">
|
||||
<a href={logoLinkUrl} className="nodedc-expanded-brand-link" aria-label="NODE.DC">
|
||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="nodedc-expanded-toolbar-center" />
|
||||
<div className="nodedc-expanded-toolbar-right">
|
||||
{showUserControls && (
|
||||
<div className="nodedc-expanded-user-group">
|
||||
{onOpenNotifications && (
|
||||
<Tooltip tooltipContent={t("notification.label")} position="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-toolbar-icon-button nodedc-expanded-notification-button relative flex items-center justify-center"
|
||||
data-active={false}
|
||||
aria-label={t("notification.label")}
|
||||
onClick={onOpenNotifications}
|
||||
>
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<InboxIcon className="size-5" />
|
||||
</span>
|
||||
{notificationsCount > 0 && (
|
||||
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<UserMenuRoot variant="expanded-toolbar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-[1] flex min-h-screen w-full items-center justify-center px-5 py-10 pt-[calc(var(--nodedc-shell-height)+2.25rem)]">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
@@ -32,11 +32,25 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const {
|
||||
project: { projectMemberIds, getFilteredProjectMemberDetails, filters },
|
||||
project: {
|
||||
fetchProjectMembers,
|
||||
getFilteredProjectMemberDetails,
|
||||
getProjectMemberFetchStatus,
|
||||
getProjectMemberIds,
|
||||
filters,
|
||||
},
|
||||
} = useMember();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId.toString());
|
||||
const projectMemberIds = getProjectMemberIds(projectId.toString(), true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
void fetchProjectMembers(workspaceSlug.toString(), projectId.toString(), true).catch(console.error);
|
||||
}, [fetchProjectMembers, projectId, workspaceSlug]);
|
||||
|
||||
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
|
||||
const memberDetails = projectId ? getFilteredProjectMemberDetails(userId, projectId.toString()) : null;
|
||||
@@ -53,7 +67,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
||||
projectId ? getFilteredProjectMemberDetails(memberId, projectId.toString()) : null
|
||||
);
|
||||
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
// Handler for role filter updates
|
||||
const handleRoleFilterUpdate = (role: string) => {
|
||||
@@ -90,7 +104,6 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-13 placeholder:text-placeholder focus:outline-none"
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -113,7 +126,7 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!projectMemberIds ? (
|
||||
{!hasFetchedProjectMembers ? (
|
||||
<MembersSettingsLoader />
|
||||
) : (
|
||||
<div className="nodedc-settings-card overflow-hidden px-1 py-1">
|
||||
|
||||
@@ -21,21 +21,25 @@ type Props = {
|
||||
value: any;
|
||||
onChange: (val: string) => void;
|
||||
isDisabled?: boolean;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const MemberSelect = observer(function MemberSelect(props: Props) {
|
||||
const { value, onChange, isDisabled = false } = props;
|
||||
const { value, onChange, isDisabled = false, projectId: explicitProjectId } = props;
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
const { projectId: routeProjectId } = useParams();
|
||||
const projectId = explicitProjectId ?? routeProjectId?.toString();
|
||||
// store hooks
|
||||
const {
|
||||
project: { projectMemberIds, getProjectMemberDetails },
|
||||
project: { getProjectMemberDetails, getProjectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId, true) : null;
|
||||
|
||||
const options = projectMemberIds
|
||||
?.map((userId) => {
|
||||
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
|
||||
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId) : null;
|
||||
|
||||
if (!memberDetails?.member) return;
|
||||
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
|
||||
@@ -59,7 +63,7 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
||||
content: React.ReactNode;
|
||||
}[]
|
||||
| undefined;
|
||||
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
|
||||
const selectedOption = projectId ? getProjectMemberDetails(value, projectId) : null;
|
||||
|
||||
return (
|
||||
<SearchSelectionDropdown
|
||||
@@ -81,7 +85,6 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
||||
}
|
||||
buttonClassName="nodedc-settings-select !w-full !justify-between !px-4 !py-3"
|
||||
options={
|
||||
options &&
|
||||
options && [
|
||||
...options,
|
||||
{
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("project_settings.general.toast.success"),
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
@@ -131,6 +132,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("project_settings.general.toast.success"),
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
@@ -154,6 +156,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||
submitChanges({ project_lead: val });
|
||||
}}
|
||||
isDisabled={!isAdmin}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -178,6 +181,7 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
||||
submitChanges({ default_assignee: val });
|
||||
}}
|
||||
isDisabled={!isAdmin}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, BellRing, FolderKanban, UserRound, UsersRound } from "lucide-react";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
import { closeWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
|
||||
import { useNotification } from "@/hooks/store/notifications/use-notification";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { NotificationOption } from "../sidebar/notification-card/options";
|
||||
|
||||
type TNodeDCNotificationDetailProps = {
|
||||
notificationId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const NodeDCNotificationDetail = (props: TNodeDCNotificationDetailProps) => {
|
||||
const { notificationId, workspaceSlug } = props;
|
||||
const router = useAppRouter();
|
||||
const { asJson: notification } = useNotification(notificationId);
|
||||
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
|
||||
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
|
||||
|
||||
if (!notification?.id) return <></>;
|
||||
|
||||
const targetUrl = notification.data?.target_url;
|
||||
const isProjectTarget = !!notification.data?.project_id || notification.sender?.includes("project_");
|
||||
const actionLabel = isProjectTarget ? "Открыть проект" : "Перейти в пространство";
|
||||
const actor = notification.triggered_by_details;
|
||||
const contextItems = [
|
||||
{
|
||||
icon: <UsersRound className="size-4" />,
|
||||
label: "Workspace",
|
||||
value: notification.data?.workspace_name,
|
||||
},
|
||||
{
|
||||
icon: <FolderKanban className="size-4" />,
|
||||
label: "Проект",
|
||||
value: notification.data?.project_name,
|
||||
},
|
||||
{
|
||||
icon: <UserRound className="size-4" />,
|
||||
label: "Роль",
|
||||
value: notification.data?.role,
|
||||
},
|
||||
].filter((item) => item.value);
|
||||
|
||||
const handleOpenTarget = () => {
|
||||
if (!targetUrl) return;
|
||||
|
||||
closeWorkspaceNotificationsModal();
|
||||
router.push(targetUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-8 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-13 font-semibold tracking-[0.16em] text-[rgb(var(--nodedc-accent-rgb))] uppercase">
|
||||
NODE.DC уведомление
|
||||
</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
{notification.created_at ? calculateTimeAgo(notification.created_at) : "Новое событие"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{targetUrl && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleOpenTarget}
|
||||
className="nodedc-empty-state-primary min-w-[12rem]"
|
||||
appendIcon={<ArrowRight className="size-4" />}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<NotificationOption
|
||||
workspaceSlug={workspaceSlug}
|
||||
notificationId={notification.id}
|
||||
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
|
||||
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
|
||||
customSnoozeModal={customSnoozeModal}
|
||||
setCustomSnoozeModal={setCustomSnoozeModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="nodedc-glass-surface relative overflow-hidden rounded-[2rem] px-8 py-8">
|
||||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[rgb(var(--nodedc-accent-rgb))]/55 to-transparent" />
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/[0.045]">
|
||||
{actor ? (
|
||||
<Avatar
|
||||
name={actor.display_name || actor.first_name}
|
||||
src={getFileURL(actor.avatar_url)}
|
||||
size={64}
|
||||
shape="circle"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<BellRing className="size-8 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-24 font-semibold tracking-[-0.03em] text-primary">
|
||||
{notification.title || "Новое событие в Tasker"}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-15 leading-7 text-secondary">
|
||||
{notification.message_stripped ||
|
||||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{actor?.display_name && (
|
||||
<div className="mt-5 text-13 text-tertiary">
|
||||
Инициатор: <span className="text-secondary">{actor.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextItems.length > 0 && (
|
||||
<div className="mt-8 grid gap-3 md:grid-cols-3">
|
||||
{contextItems.map((item) => (
|
||||
<div key={item.label} className="rounded-[1.25rem] bg-white/[0.035] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-12 font-semibold tracking-[0.14em] text-tertiary uppercase">
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-15 font-semibold text-primary">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,9 @@ import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { NodeDCNotificationDetail } from "@/components/workspace-notifications/detail/nodedc-notification-detail";
|
||||
// hooks
|
||||
import { useNotification } from "@/hooks/store/notifications/use-notification";
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
@@ -40,9 +42,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
||||
} = useWorkspaceNotifications();
|
||||
const { fetchUserProjectInfo } = useUserPermissions();
|
||||
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
|
||||
const { asJson: selectedNotification } = useNotification(currentSelectedNotificationId);
|
||||
// derived values
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
|
||||
notificationLiteByNotificationId(currentSelectedNotificationId);
|
||||
const isNodeDCNotification = selectedNotification?.sender?.startsWith("in_app:nodedc:") ?? false;
|
||||
|
||||
// fetching workspace work item properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
@@ -128,6 +132,8 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : isNodeDCNotification && workspaceSlug ? (
|
||||
<NodeDCNotificationDetail notificationId={currentSelectedNotificationId} workspaceSlug={workspaceSlug} />
|
||||
) : (
|
||||
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
|
||||
)}
|
||||
|
||||
+4
-4
@@ -160,10 +160,10 @@ export function NotificationContent({
|
||||
renderCommentBox?: boolean;
|
||||
}) {
|
||||
const { data, triggered_by_details: triggeredBy } = notification;
|
||||
const notificationField = data?.issue_activity.field;
|
||||
const newValue = data?.issue_activity.new_value;
|
||||
const oldValue = data?.issue_activity.old_value;
|
||||
const verb = data?.issue_activity.verb;
|
||||
const notificationField = data?.issue_activity?.field;
|
||||
const newValue = data?.issue_activity?.new_value;
|
||||
const oldValue = data?.issue_activity?.old_value;
|
||||
const verb = data?.issue_activity?.verb;
|
||||
|
||||
const fieldData: TNotificationFieldData = {
|
||||
field: notificationField,
|
||||
|
||||
+59
-13
@@ -40,8 +40,10 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
const issueId = notification?.data?.issue?.id || undefined;
|
||||
const workspace = getWorkspaceBySlug(workspaceSlug);
|
||||
|
||||
const notificationField = notification?.data?.issue_activity.field || undefined;
|
||||
const notificationField = notification?.data?.issue_activity?.field || undefined;
|
||||
const notificationTriggeredBy = notification.triggered_by_details || undefined;
|
||||
const isNodeDCNotification = notification.sender?.startsWith("in_app:nodedc:") ?? false;
|
||||
const isIssueNotification = !!notificationField && !!projectId && !!issueId && notification.entity_name === "issue";
|
||||
|
||||
const handleNotificationIssuePeekOverview = async () => {
|
||||
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
|
||||
@@ -65,8 +67,32 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
|
||||
return <></>;
|
||||
const handleNodeDCNotification = async () => {
|
||||
if (isSnoozeStateModalOpen || customSnoozeModal) return;
|
||||
|
||||
setPeekIssue(undefined);
|
||||
setCurrentSelectedNotificationId(notificationId);
|
||||
|
||||
if (notification.read_at === null) {
|
||||
try {
|
||||
await markNotificationAsRead(workspaceSlug);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = () => {
|
||||
if (isIssueNotification) {
|
||||
void handleNotificationIssuePeekOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNodeDCNotification) void handleNodeDCNotification();
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !notificationId || !notification?.id || !workspace?.id) return <></>;
|
||||
if (!isIssueNotification && !isNodeDCNotification) return <></>;
|
||||
|
||||
return (
|
||||
<Row
|
||||
@@ -77,7 +103,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
"bg-accent-primary/5": notification.read_at === null,
|
||||
}
|
||||
)}
|
||||
onClick={handleNotificationIssuePeekOverview}
|
||||
onClick={handleNotificationClick}
|
||||
>
|
||||
{notification.read_at === null && (
|
||||
<div className="absolute top-[50%] left-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-primary" />
|
||||
@@ -85,7 +111,7 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
|
||||
<div className="relative flex w-full gap-2">
|
||||
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
|
||||
{notificationTriggeredBy && (
|
||||
{notificationTriggeredBy ? (
|
||||
<Avatar
|
||||
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
|
||||
src={getFileURL(notificationTriggeredBy.avatar_url)}
|
||||
@@ -93,18 +119,29 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
shape="circle"
|
||||
className="bg-layer-1 text-body-sm-medium"
|
||||
/>
|
||||
) : (
|
||||
<img src="/nodedc-logo.svg" alt="NODE DC" className="h-6 w-auto opacity-80" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 w-full space-y-1">
|
||||
<div className="relative flex h-8 items-center gap-3">
|
||||
<div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
|
||||
<NotificationContent
|
||||
notification={notification}
|
||||
workspaceId={workspace.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
{isIssueNotification && projectId ? (
|
||||
<NotificationContent
|
||||
notification={notification}
|
||||
workspaceId={workspace.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{notificationTriggeredBy?.display_name && (
|
||||
<span className="font-medium text-primary">{notificationTriggeredBy.display_name} </span>
|
||||
)}
|
||||
<span className="text-tertiary">{notification.title}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<NotificationOption
|
||||
workspaceSlug={workspaceSlug}
|
||||
@@ -118,8 +155,17 @@ export const NotificationItem = observer(function NotificationItem(props: TNotif
|
||||
|
||||
<div className="relative flex items-center gap-3 text-caption-sm-regular text-secondary">
|
||||
<div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
|
||||
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}
|
||||
{notification?.data?.issue?.name}
|
||||
{isIssueNotification ? (
|
||||
<>
|
||||
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}
|
||||
{notification?.data?.issue?.name}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{notification.message_stripped ||
|
||||
[notification.data?.project_name, notification.data?.workspace_name].filter(Boolean).join(" · ")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{notification?.snoozed_till ? (
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// helpers
|
||||
@@ -17,6 +18,10 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type TPageType = EPageTypes;
|
||||
|
||||
@@ -35,6 +40,7 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams.get("next_path");
|
||||
const pendingInviteToastKey = useRef<string | undefined>(undefined);
|
||||
// props
|
||||
const { children, pageType = EPageTypes.AUTHENTICATED } = props;
|
||||
// hooks
|
||||
@@ -55,6 +61,47 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
||||
currentUserProfile?.onboarding_step?.workspace_invite &&
|
||||
currentUserProfile?.onboarding_step?.workspace_join) ||
|
||||
false;
|
||||
const shouldWatchPendingInvites =
|
||||
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
|
||||
|
||||
const { data: pendingWorkspaceInvitations } = useSWR(
|
||||
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
|
||||
() => workspaceService.userWorkspaceInvitations(),
|
||||
{
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldWatchPendingInvites || !pendingWorkspaceInvitations?.length) return;
|
||||
|
||||
const inviteKey = pendingWorkspaceInvitations
|
||||
.map((invitation) => invitation.id)
|
||||
.toSorted()
|
||||
.join(":");
|
||||
if (pendingInviteToastKey.current === inviteKey) return;
|
||||
pendingInviteToastKey.current = inviteKey;
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.INFO,
|
||||
title: "Новое приглашение в Tasker",
|
||||
message:
|
||||
pendingWorkspaceInvitations.length === 1
|
||||
? "Вам отправили доступ в workspace. Откройте приглашения, чтобы принять его."
|
||||
: `У вас ${pendingWorkspaceInvitations.length} ожидающих приглашений. Откройте список, чтобы принять доступы.`,
|
||||
actionItems: (
|
||||
<button
|
||||
type="button"
|
||||
className="text-12 font-semibold text-[rgb(var(--nodedc-accent-rgb))] transition hover:opacity-80"
|
||||
onClick={() => router.push("/invitations")}
|
||||
>
|
||||
Открыть приглашения
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}, [pendingWorkspaceInvitations, router, shouldWatchPendingInvites]);
|
||||
|
||||
const getWorkspaceRedirectionUrl = (): string => {
|
||||
let redirectionRoute = "/create-workspace";
|
||||
|
||||
Reference in New Issue
Block a user