Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,40 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { getNumberCount } from "@plane/utils";
// components
import { CountChip } from "@/components/common/count-chip";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TNotificationAppSidebarOption = {
workspaceSlug: string;
};
export const NotificationAppSidebarOption: FC<TNotificationAppSidebarOption> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug) : null
);
// derived values
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0 ? true : false;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
if (totalNotifications <= 0) return <></>;
return (
<div className="ml-auto">
<CountChip count={`${isMentionsEnabled ? `@ ` : ``}${getNumberCount(totalNotifications)}`} />
</div>
);
});

View File

@@ -0,0 +1,118 @@
"use client";
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
// plane web imports
import { useNotificationPreview } from "@/plane-web/hooks/use-notification-preview";
// local imports
import { InboxContentRoot } from "../inbox/content";
type NotificationsRootProps = {
workspaceSlug?: string;
};
export const NotificationsRoot = observer(({ workspaceSlug }: NotificationsRootProps) => {
// plane hooks
const { t } = useTranslation();
// hooks
const { currentWorkspace } = useWorkspace();
const {
currentSelectedNotificationId,
setCurrentSelectedNotificationId,
notificationLiteByNotificationId,
notificationIdsByWorkspaceId,
getNotifications,
} = useWorkspaceNotifications();
const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
// derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
notificationLiteByNotificationId(currentSelectedNotificationId);
// fetching workspace work item properties
useWorkspaceIssueProperties(workspaceSlug);
// fetch workspace notifications
const notificationMutation =
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
? ENotificationLoader.MUTATION_LOADER
: ENotificationLoader.INIT_LOADER;
const notificationLoader =
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
? ENotificationQueryParamType.CURRENT
: ENotificationQueryParamType.INIT;
useSWR(
currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION_${currentWorkspace?.slug}` : null,
currentWorkspace?.slug
? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader)
: null
);
// fetching user project member info
const { isLoading: projectMemberInfoLoader } = useSWR(
workspace_slug && project_id && is_inbox_issue
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
: null,
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
);
const embedRemoveCurrentNotification = useCallback(
() => setCurrentSelectedNotificationId(undefined),
[setCurrentSelectedNotificationId]
);
// clearing up the selected notifications when unmounting the page
useEffect(
() => () => {
setPeekWorkItem(undefined);
},
[setCurrentSelectedNotificationId, setPeekWorkItem]
);
return (
<div className={cn("w-full h-full overflow-hidden ", isWorkItem && "overflow-y-auto")}>
{!currentSelectedNotificationId ? (
<div className="flex justify-center items-center size-full">
<EmptyStateCompact assetKey="unknown" assetClassName="size-20" />
</div>
) : (
<>
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
<>
{projectMemberInfoLoader ? (
<div className="w-full h-full flex justify-center items-center">
<LogoSpinner />
</div>
) : (
<InboxContentRoot
setIsMobileSidebar={() => {}}
isMobileSidebar={false}
workspaceSlug={workspace_slug}
projectId={project_id}
inboxIssueId={issue_id}
isNotificationEmbed
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/>
)}
</>
) : (
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)}
</>
)}
</div>
);
});

View File

@@ -0,0 +1,32 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { ENotificationTab } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
type TNotificationEmptyStateProps = {
currentNotificationTab: ENotificationTab;
};
export const NotificationEmptyState: FC<TNotificationEmptyStateProps> = observer(({ currentNotificationTab }) => {
// plane imports
const { t } = useTranslation();
return (
<>
<EmptyStateCompact
assetKey="inbox"
assetClassName="size-24"
title={
currentNotificationTab === ENotificationTab.ALL
? t("workspace_empty_state.inbox_sidebar_all.title")
: t("workspace_empty_state.inbox_sidebar_mentions.title")
}
className="max-w-56"
/>
</>
);
});

View File

@@ -0,0 +1,68 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { ENotificationFilterType, FILTER_TYPE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons";
import { Header, EHeaderVariant, Tag } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TAppliedFilters = {
workspaceSlug: string;
};
export const AppliedFilters: FC<TAppliedFilters> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { filters, updateFilters } = useWorkspaceNotifications();
const { t } = useTranslation();
// derived values
const isFiltersEnabled = Object.entries(filters.type || {}).some(([, value]) => value);
const handleFilterTypeChange = (filterType: ENotificationFilterType, filterValue: boolean) =>
updateFilters("type", {
...filters.type,
[filterType]: filterValue,
});
const handleClearFilters = () => {
updateFilters("type", {
[ENotificationFilterType.ASSIGNED]: false,
[ENotificationFilterType.CREATED]: false,
[ENotificationFilterType.SUBSCRIBED]: false,
});
};
if (!isFiltersEnabled || !workspaceSlug) return <></>;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem className="w-full">
{FILTER_TYPE_OPTIONS.map((filter) => {
const isSelected = filters?.type?.[filter?.value] || false;
if (!isSelected) return <></>;
return (
<Tag
key={filter.value}
className="flex flex-wrap flex-start"
onClick={() => handleFilterTypeChange(filter?.value, !isSelected)}
>
<div className="whitespace-nowrap text-custom-text-200">{t(filter.i18n_label)}</div>
<div className="w-4 h-4 flex justify-center items-center transition-all rounded-sm text-custom-text-200 hover:text-custom-text-100">
<CloseIcon className="h-3 w-3" />
</div>
</Tag>
);
})}
<button type="button" onClick={handleClearFilters}>
<Tag>
{t("common.clear_all")}
<CloseIcon height={12} width={12} strokeWidth={2} />
</Tag>
</button>
</Header.LeftItem>
</Header>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,46 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane imports
import type { ENotificationFilterType } from "@plane/constants";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const NotificationFilterOptionItem: FC<{ label: string; value: ENotificationFilterType }> = observer((props) => {
const { value, label } = props;
// hooks
const { filters, updateFilters } = useWorkspaceNotifications();
const handleFilterTypeChange = (filterType: ENotificationFilterType, filterValue: boolean) =>
updateFilters("type", {
...filters.type,
[filterType]: filterValue,
});
// derived values
const isSelected = filters?.type?.[value] || false;
return (
<div
key={value}
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => handleFilterTypeChange(value, !isSelected)}
>
<div
className={cn("flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all", {
"bg-custom-primary text-white": isSelected,
"bg-custom-background-90": !isSelected,
})}
>
{isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<div className={cn("whitespace-nowrap text-sm", isSelected ? "text-custom-text-100" : "text-custom-text-200")}>
{label}
</div>
</div>
);
});

View File

@@ -0,0 +1,41 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { ListFilter } from "lucide-react";
// plane imports
import type { ENotificationFilterType } from "@plane/constants";
import { FILTER_TYPE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { PopoverMenu } from "@plane/ui";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { NotificationFilterOptionItem } from "./menu-option-item";
export const NotificationFilter: FC = observer(() => {
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const translatedFilterTypeOptions = FILTER_TYPE_OPTIONS.map((filter) => ({
...filter,
label: t(filter.i18n_label),
}));
return (
<PopoverMenu
data={translatedFilterTypeOptions}
button={
<Tooltip tooltipContent={t("notification.options.filters")} isMobile={isMobile} position="bottom">
<div className="flex-shrink-0 w-5 h-5 flex justify-center items-center overflow-hidden cursor-pointer transition-all hover:bg-custom-background-80 rounded-sm outline-none">
<ListFilter className="h-3 w-3" />
</div>
</Tooltip>
}
keyExtractor={(item: { label: string; value: ENotificationFilterType }) => item.value}
render={(item) => <NotificationFilterOptionItem {...item} />}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,28 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// components
import { cn } from "@plane/utils";
// local imports
import type { TPopoverMenuOptions } from "./root";
export const NotificationMenuOptionItem: FC<TPopoverMenuOptions> = observer((props) => {
const { type, label = "", isActive, prependIcon, appendIcon, onClick } = props;
if (type === "menu-item")
return (
<div
className="flex items-center gap-2 cursor-pointer mx-2 px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => onClick && onClick()}
>
{prependIcon && prependIcon}
<div className={cn("whitespace-nowrap text-sm", isActive ? "text-custom-text-100" : "text-custom-text-200")}>
{label}
</div>
{appendIcon && <div className="ml-auto">{appendIcon}</div>}
</div>
);
return <div className="border-b border-custom-border-200" />;
});

View File

@@ -0,0 +1,83 @@
"use client";
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { Check, CheckCircle, Clock } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { ArchiveIcon } from "@plane/propel/icons";
import type { TNotificationFilter } from "@plane/types";
import { PopoverMenu } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
// local imports
import { NotificationMenuOptionItem } from "./menu-item";
export type TPopoverMenuOptions = {
key: string;
type: string;
label?: string | undefined;
isActive?: boolean | undefined;
prependIcon?: ReactNode | undefined;
appendIcon?: ReactNode | undefined;
onClick?: (() => void) | undefined;
};
export const NotificationHeaderMenuOption = observer(() => {
// hooks
const { filters, updateFilters, updateBulkFilters } = useWorkspaceNotifications();
const { t } = useTranslation();
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
updateFilters(filterType, filterValue);
const handleBulkFilterChange = (filter: Partial<TNotificationFilter>) => updateBulkFilters(filter);
const popoverMenuOptions: TPopoverMenuOptions[] = [
{
key: "menu-unread",
type: "menu-item",
label: t("notification.options.show_unread"),
isActive: filters?.read,
prependIcon: <CheckCircle className="flex-shrink-0 h-3 w-3" />,
appendIcon: filters?.read ? <Check className="w-3 h-3" /> : undefined,
onClick: () => handleFilterChange("read", !filters?.read),
},
{
key: "menu-archived",
type: "menu-item",
label: t("notification.options.show_archived"),
isActive: filters?.archived,
prependIcon: <ArchiveIcon className="flex-shrink-0 h-3 w-3" />,
appendIcon: filters?.archived ? <Check className="w-3 h-3" /> : undefined,
onClick: () =>
handleBulkFilterChange({
archived: !filters?.archived,
snoozed: false,
}),
},
{
key: "menu-snoozed",
type: "menu-item",
label: t("notification.options.show_snoozed"),
isActive: filters?.snoozed,
prependIcon: <Clock className="flex-shrink-0 h-3 w-3" />,
appendIcon: filters?.snoozed ? <Check className="w-3 h-3" /> : undefined,
onClick: () =>
handleBulkFilterChange({
snoozed: !filters?.snoozed,
archived: false,
}),
},
];
return (
<PopoverMenu
data={popoverMenuOptions}
buttonClassName="flex-shrink-0 w-5 h-5 flex justify-center items-center overflow-hidden cursor-pointer transition-all hover:bg-custom-background-80 bg-custom-background-100 rounded-sm outline-none"
keyExtractor={(item: TPopoverMenuOptions) => item.key}
panelClassName="p-0 py-2 rounded-md border border-custom-border-200 bg-custom-background-100 space-y-1"
render={(item: TPopoverMenuOptions) => <NotificationMenuOptionItem {...item} />}
/>
);
});

View File

@@ -0,0 +1,92 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import { CheckCheck, RefreshCw } from "lucide-react";
// plane imports
import {
ENotificationLoader,
ENotificationQueryParamType,
NOTIFICATION_TRACKER_ELEMENTS,
NOTIFICATION_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { Spinner } from "@plane/ui";
// helpers
import { captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { NotificationFilter } from "../../filters/menu";
import { NotificationHeaderMenuOption } from "./menu-option";
type TNotificationSidebarHeaderOptions = {
workspaceSlug: string;
};
export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOptions> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
const { t } = useTranslation();
const refreshNotifications = async () => {
if (loader) return;
try {
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
} catch (error) {
console.error(error);
}
};
const handleMarkAllNotificationsAsRead = async () => {
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
if (loader) return;
try {
await markAllNotificationsAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
};
return (
<div className="relative flex justify-center items-center gap-2 text-sm">
{/* mark all notifications as read*/}
<Tooltip tooltipContent={t("notification.options.mark_all_as_read")} isMobile={isMobile} position="bottom">
<div
className="flex-shrink-0 w-5 h-5 flex justify-center items-center overflow-hidden cursor-pointer transition-all hover:bg-custom-background-80 rounded-sm"
data-ph-element={NOTIFICATION_TRACKER_ELEMENTS.MARK_ALL_AS_READ_BUTTON}
onClick={() => {
captureSuccess({
eventName: NOTIFICATION_TRACKER_EVENTS.all_marked_read,
});
handleMarkAllNotificationsAsRead();
}}
>
{loader === ENotificationLoader.MARK_ALL_AS_READY ? (
<Spinner height="14px" width="14px" />
) : (
<CheckCheck className="h-3 w-3" />
)}
</div>
</Tooltip>
{/* refetch current notifications */}
<Tooltip tooltipContent={t("notification.options.refresh")} isMobile={isMobile} position="bottom">
<div
className="flex-shrink-0 w-5 h-5 flex justify-center items-center overflow-hidden cursor-pointer transition-all hover:bg-custom-background-80 rounded-sm"
onClick={refreshNotifications}
>
<RefreshCw className={`h-3 w-3 ${loader === ENotificationLoader.MUTATION_LOADER ? "animate-spin" : ""}`} />
</div>
</Tooltip>
{/* notification filters */}
<NotificationFilter />
{/* notification menu options */}
<NotificationHeaderMenuOption />
</div>
);
});

View File

@@ -0,0 +1,49 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
// local imports
import { NotificationSidebarHeaderOptions } from "./options";
type TNotificationSidebarHeader = {
workspaceSlug: string;
};
export const NotificationSidebarHeader: FC<TNotificationSidebarHeader> = observer((props) => {
const { workspaceSlug } = props;
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme();
if (!workspaceSlug) return <></>;
return (
<Header className="my-auto bg-custom-background-100">
<Header.LeftItem>
{sidebarCollapsed && <SidebarHamburgerToggle />}
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("notification.label")}
icon={<InboxIcon className="h-4 w-4 text-custom-text-300" />}
disableTooltip
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug} />
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,18 @@
import { range } from "lodash-es";
export const NotificationsLoader = () => (
<div className="divide-y divide-custom-border-100 animate-pulse overflow-hidden">
{range(8).map((i) => (
<div key={i} className="flex w-full items-center gap-4 p-3">
<span className="min-h-12 min-w-12 bg-custom-background-80 rounded-full" />
<div className="flex flex-col gap-2.5 w-full">
<span className="h-5 w-36 bg-custom-background-80 rounded" />
<div className="flex items-center justify-between gap-2 w-full">
<span className="h-5 w-28 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
</div>
</div>
))}
</div>
);

View File

@@ -0,0 +1,121 @@
import type { FC } from "react";
// plane imports
import type { TNotification } from "@plane/types";
import {
convertMinutesToHoursMinutesString,
renderFormattedDate,
sanitizeCommentForNotification,
replaceUnderscoreIfSnakeCase,
stripAndTruncateHTML,
} from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
export const NotificationContent: FC<{
notification: TNotification;
workspaceId: string;
workspaceSlug: string;
projectId: string;
renderCommentBox?: boolean;
}> = ({ notification, workspaceId, workspaceSlug, projectId, renderCommentBox = false }) => {
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 renderTriggerName = () => (
<span className="text-custom-text-100 font-medium">
{triggeredBy?.is_bot ? triggeredBy.first_name : triggeredBy?.display_name}{" "}
</span>
);
const renderAction = () => {
if (!notificationField) return "";
if (notificationField === "duplicate")
return verb === "created"
? "marked that this work item is a duplicate of"
: "marked that this work item is not a duplicate";
if (notificationField === "assignees") {
return newValue !== "" ? "added assignee" : "removed assignee";
}
if (notificationField === "start_date") {
return newValue !== "" ? "set start date" : "removed the start date";
}
if (notificationField === "target_date") {
return newValue !== "" ? "set due date" : "removed the due date";
}
if (notificationField === "labels") {
return newValue !== "" ? "added label" : "removed label";
}
if (notificationField === "parent") {
return newValue !== "" ? "added parent" : "removed parent";
}
if (notificationField === "relates_to") return "marked that this work item is related to";
if (notificationField === "comment") return "commented";
if (notificationField === "archived_at") {
return newValue === "restore" ? "restored the work item" : "archived the work item";
}
if (notificationField === "None") return null;
const baseAction = !["comment", "archived_at"].includes(notificationField) ? verb : "";
return `${baseAction} ${replaceUnderscoreIfSnakeCase(notificationField)}`;
};
const renderValue = () => {
if (notificationField === "None") return "the work item and assigned it to you.";
if (notificationField === "comment") return renderCommentBox ? null : sanitizeCommentForNotification(newValue);
if (notificationField === "target_date" || notificationField === "start_date") return renderFormattedDate(newValue);
if (notificationField === "attachment") return "the work item";
if (notificationField === "description") return stripAndTruncateHTML(newValue || "", 55);
if (notificationField === "archived_at") return null;
if (notificationField === "assignees") return newValue !== "" ? newValue : oldValue;
if (notificationField === "labels") return newValue !== "" ? newValue : oldValue;
if (notificationField === "parent") return newValue !== "" ? newValue : oldValue;
if (notificationField === "estimate_time")
return newValue !== ""
? convertMinutesToHoursMinutesString(Number(newValue))
: convertMinutesToHoursMinutesString(Number(oldValue));
return newValue;
};
const shouldShowConnector = ![
"comment",
"archived_at",
"None",
"assignees",
"labels",
"start_date",
"target_date",
"parent",
].includes(notificationField || "");
return (
<>
{renderTriggerName()}
<span className="text-custom-text-300">{renderAction()} </span>
{verb !== "deleted" && (
<>
{shouldShowConnector && <span className="text-custom-text-300">to </span>}
<span className="text-custom-text-100 font-medium">{renderValue()}</span>
{notificationField === "comment" && renderCommentBox && (
<div className="scale-75 origin-left">
<LiteTextEditor
editable={false}
id=""
initialValue={newValue ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
)}
{"."}
</>
)}
</>
);
};

View File

@@ -0,0 +1,137 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Clock } from "lucide-react";
// plane imports
import { Avatar, Row } from "@plane/ui";
import { cn, calculateTimeAgo, renderFormattedDate, renderFormattedTime, getFileURL } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { NotificationContent } from "./content";
import { NotificationOption } from "./options";
type TNotificationItem = {
workspaceSlug: string;
notificationId: string;
};
export const NotificationItem: FC<TNotificationItem> = observer((props) => {
const { workspaceSlug, notificationId } = props;
// hooks
const { currentSelectedNotificationId, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { getWorkspaceBySlug } = useWorkspace();
// states
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
// derived values
const projectId = notification?.project || undefined;
const issueId = notification?.data?.issue?.id || undefined;
const workspace = getWorkspaceBySlug(workspaceSlug);
const notificationField = notification?.data?.issue_activity.field || undefined;
const notificationTriggeredBy = notification.triggered_by_details || undefined;
const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
setPeekIssue(undefined);
setCurrentSelectedNotificationId(notificationId);
// make the notification as read
if (notification.read_at === null) {
try {
await markNotificationAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
}
if (notification?.is_inbox_issue === false) {
!getIsIssuePeeked(issueId) && setPeekIssue({ workspaceSlug, projectId, issueId });
}
}
};
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
return <></>;
return (
<Row
className={cn(
"relative py-4 flex items-center gap-2 border-b border-custom-border-200 cursor-pointer transition-all group",
currentSelectedNotificationId === notification?.id ? "bg-custom-background-80/30" : "",
notification.read_at === null ? "bg-custom-primary-100/5" : ""
)}
onClick={handleNotificationIssuePeekOverview}
>
{notification.read_at === null && (
<div className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-custom-primary-100 absolute top-[50%] left-2" />
)}
<div className="relative w-full flex gap-2">
<div className="flex-shrink-0 relative flex justify-center items-center w-12 h-12 bg-custom-background-80 rounded-full">
{notificationTriggeredBy && (
<Avatar
name={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
src={getFileURL(notificationTriggeredBy.avatar_url)}
size={42}
shape="circle"
className="!text-base !bg-custom-background-80"
/>
)}
</div>
<div className="w-full space-y-1 -mt-2">
<div className="relative flex items-center gap-3 h-8">
<div className="w-full overflow-hidden whitespace-normal break-all truncate line-clamp-1 text-sm text-custom-text-100">
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
<NotificationOption
workspaceSlug={workspaceSlug}
notificationId={notification?.id}
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
<div className="relative flex items-center gap-3 text-xs text-custom-text-200">
<div className="w-full overflow-hidden whitespace-normal break-words truncate line-clamp-1">
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
</div>
<div className="flex-shrink-0">
{notification?.snoozed_till ? (
<p className="flex flex-shrink-0 items-center justify-end gap-x-1 text-custom-text-300">
<Clock className="h-4 w-4" />
<span>
Till {renderFormattedDate(notification.snoozed_till)},&nbsp;
{renderFormattedTime(notification.snoozed_till, "12-hour")}
</span>
</p>
) : (
<p className="mt-auto flex-shrink-0 text-custom-text-300">
{notification.created_at && calculateTimeAgo(notification.created_at)}
</p>
)}
</div>
</div>
</div>
</div>
</Row>
);
});

View File

@@ -0,0 +1,74 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { ArchiveRestore } from "lucide-react";
// plane imports
import { NOTIFICATION_TRACKER_ELEMENTS, NOTIFICATION_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ArchiveIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationItemOptionButton } from "./button";
type TNotificationItemArchiveOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemArchiveOption: FC<TNotificationItemArchiveOption> = observer((props) => {
const { workspaceSlug, notification } = props;
// hooks
const { currentNotificationTab } = useWorkspaceNotifications();
const { asJson: data, archiveNotification, unArchiveNotification } = notification;
const { t } = useTranslation();
const handleNotificationUpdate = async () => {
try {
const request = data.archived_at ? unArchiveNotification : archiveNotification;
await request(workspaceSlug);
captureSuccess({
eventName: data.archived_at ? NOTIFICATION_TRACKER_EVENTS.unarchive : NOTIFICATION_TRACKER_EVENTS.archive,
payload: {
id: data?.data?.issue?.id,
tab: currentNotificationTab,
},
});
setToast({
title: data.archived_at ? t("notification.toasts.unarchived") : t("notification.toasts.archived"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
captureError({
eventName: data.archived_at ? NOTIFICATION_TRACKER_EVENTS.unarchive : NOTIFICATION_TRACKER_EVENTS.archive,
payload: {
id: data?.data?.issue?.id,
tab: currentNotificationTab,
},
});
}
};
return (
<NotificationItemOptionButton
data-ph-element={NOTIFICATION_TRACKER_ELEMENTS.ARCHIVE_UNARCHIVE_BUTTON}
tooltipContent={
data.archived_at ? t("notification.options.mark_unarchive") : t("notification.options.mark_archive")
}
callBack={handleNotificationUpdate}
>
{data.archived_at ? (
<ArchiveRestore className="h-3 w-3 text-custom-text-300" />
) : (
<ArchiveIcon className="h-3 w-3 text-custom-text-300" />
)}
</NotificationItemOptionButton>
);
});

View File

@@ -0,0 +1,40 @@
"use client";
import type { FC, ReactNode } from "react";
import { Tooltip } from "@plane/propel/tooltip";
// helpers
import { cn } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type TNotificationItemOptionButton = {
tooltipContent?: string;
buttonClassName?: string;
callBack: () => void;
children: ReactNode;
};
export const NotificationItemOptionButton: FC<TNotificationItemOptionButton> = (props) => {
const { tooltipContent = "", buttonClassName = "", children, callBack } = props;
// hooks
const { isMobile } = usePlatformOS();
return (
<Tooltip tooltipContent={tooltipContent} isMobile={isMobile}>
<button
type="button"
className={cn(
"relative flex-shrink-0 w-5 h-5 rounded-sm flex justify-center items-center outline-none bg-custom-background-80 hover:bg-custom-background-90",
buttonClassName
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
callBack && callBack();
}}
>
{children}
</button>
</Tooltip>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./root";
export * from "./read";
export * from "./archive";
export * from "./snooze";
export * from "./button";

View File

@@ -0,0 +1,67 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
// plane imports
import { NOTIFICATION_TRACKER_ELEMENTS, NOTIFICATION_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationItemOptionButton } from "./button";
type TNotificationItemReadOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemReadOption: FC<TNotificationItemReadOption> = observer((props) => {
const { workspaceSlug, notification } = props;
// hooks
const { currentNotificationTab } = useWorkspaceNotifications();
const { asJson: data, markNotificationAsRead, markNotificationAsUnRead } = notification;
const { t } = useTranslation();
const handleNotificationUpdate = async () => {
try {
const request = data.read_at ? markNotificationAsUnRead : markNotificationAsRead;
await request(workspaceSlug);
captureSuccess({
eventName: data.read_at ? NOTIFICATION_TRACKER_EVENTS.mark_unread : NOTIFICATION_TRACKER_EVENTS.mark_read,
payload: {
id: data?.data?.issue?.id,
tab: currentNotificationTab,
},
});
setToast({
title: data.read_at ? t("notification.toasts.unread") : t("notification.toasts.read"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
captureError({
eventName: data.read_at ? NOTIFICATION_TRACKER_EVENTS.mark_unread : NOTIFICATION_TRACKER_EVENTS.mark_read,
payload: {
id: data?.data?.issue?.id,
tab: currentNotificationTab,
},
});
}
};
return (
<NotificationItemOptionButton
data-ph-element={NOTIFICATION_TRACKER_ELEMENTS.MARK_READ_UNREAD_BUTTON}
tooltipContent={data.read_at ? t("notification.options.mark_unread") : t("notification.options.mark_read")}
callBack={handleNotificationUpdate}
>
<MessageSquare className="h-3 w-3 text-custom-text-300" />
</NotificationItemOptionButton>
);
});

View File

@@ -0,0 +1,55 @@
"use client";
import type { FC, Dispatch, SetStateAction } from "react";
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
// hooks
import { useNotification } from "@/hooks/store/notifications/use-notification";
// local imports
import { NotificationItemArchiveOption } from "./archive";
import { NotificationItemReadOption } from "./read";
import { NotificationItemSnoozeOption } from "./snooze";
type TNotificationOption = {
workspaceSlug: string;
notificationId: string;
isSnoozeStateModalOpen: boolean;
setIsSnoozeStateModalOpen: Dispatch<SetStateAction<boolean>>;
customSnoozeModal: boolean;
setCustomSnoozeModal: Dispatch<SetStateAction<boolean>>;
};
export const NotificationOption: FC<TNotificationOption> = observer((props) => {
const {
workspaceSlug,
notificationId,
isSnoozeStateModalOpen,
setIsSnoozeStateModalOpen,
customSnoozeModal,
setCustomSnoozeModal,
} = props;
// hooks
const notification = useNotification(notificationId);
return (
<div className={cn("flex-shrink-0 hidden group-hover:block text-sm", isSnoozeStateModalOpen ? `!block` : ``)}>
<div className="relative flex justify-center items-center gap-2">
{/* read */}
<NotificationItemReadOption workspaceSlug={workspaceSlug} notification={notification} />
{/* archive */}
<NotificationItemArchiveOption workspaceSlug={workspaceSlug} notification={notification} />
{/* snooze notification */}
<NotificationItemSnoozeOption
workspaceSlug={workspaceSlug}
notification={notification}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./root";
export * from "./modal";

View File

@@ -0,0 +1,263 @@
"use client";
import type { FC } from "react";
import { Fragment } from "react";
import { useParams } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { Transition, Dialog } from "@headlessui/react";
// plane imports
import { allTimeIn30MinutesInterval12HoursFormat } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { CloseIcon } from "@plane/propel/icons";
import { CustomSelect } from "@plane/ui";
// components
import { getDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
// helpers
type TNotificationSnoozeModal = {
isOpen: boolean;
onClose: () => void;
onSubmit: (dateTime?: Date | undefined) => Promise<void>;
};
type FormValues = {
time: string | undefined;
date: Date | undefined;
period: "AM" | "PM";
};
const defaultValues: FormValues = {
time: undefined,
date: undefined,
period: "AM",
};
const timeStamps = allTimeIn30MinutesInterval12HoursFormat;
export const NotificationSnoozeModal: FC<TNotificationSnoozeModal> = (props) => {
const { isOpen, onClose, onSubmit: handleSubmitSnooze } = props;
const { workspaceSlug } = useParams();
const {
formState: { isSubmitting },
reset,
handleSubmit,
control,
watch,
setValue,
} = useForm({
defaultValues,
});
const handleClose = () => {
// This is a workaround to fix the issue of the Notification popover modal close on closing this modal
const closeTimeout = setTimeout(() => {
onClose();
clearTimeout(closeTimeout);
}, 50);
const timeout = setTimeout(() => {
reset({ ...defaultValues });
clearTimeout(timeout);
}, 500);
};
const getTimeStamp = () => {
const today = new Date();
const formDataDate = watch("date");
if (!formDataDate) return timeStamps;
const isToday = today.toDateString() === getDate(formDataDate)?.toDateString();
if (!isToday) return timeStamps;
const hours = today.getHours();
const minutes = today.getMinutes();
return timeStamps.filter((optionTime) => {
let optionHours = parseInt(optionTime.value.split(":")[0]);
const optionMinutes = parseInt(optionTime.value.split(":")[1]);
const period = watch("period");
if (period === "PM" && optionHours !== 12) optionHours += 12;
if (optionHours < hours) return false;
if (optionHours === hours && optionMinutes < minutes) return false;
return true;
});
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !formData.date || !formData.time) return;
const period = formData.period;
const time = formData.time.split(":");
const hours = parseInt(
`${period === "AM" ? time[0] : parseInt(time[0]) + 12 === 24 ? "00" : parseInt(time[0]) + 12}`
);
const minutes = parseInt(time[1]);
const dateTime: Date | undefined = getDate(formData?.date);
dateTime?.setHours(hours);
dateTime?.setMinutes(minutes);
await handleSubmitSnooze(dateTime).then(() => {
handleClose();
});
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative w-full transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:!max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center justify-between">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customize Snooze Time
</Dialog.Title>
<div>
<button type="button" onClick={handleClose}>
<CloseIcon className="h-5 w-5 text-custom-text-100" />
</button>
</div>
</div>
<div className="mt-5 flex flex-col gap-3 md:!flex-row md:items-center">
<div className="flex-1 pb-3 md:pb-0">
<h6 className="mb-2 block text-sm font-medium text-custom-text-400">Pick a date</h6>
<Controller
name="date"
control={control}
rules={{ required: "Please select a date" }}
render={({ field: { value, onChange } }) => (
<DateDropdown
value={value || null}
placeholder="Select date"
onChange={(val) => {
setValue("time", undefined);
onChange(val);
}}
minDate={new Date()}
buttonVariant="border-with-text"
buttonContainerClassName="w-full text-left"
buttonClassName="border-custom-border-300 px-3 py-2.5"
hideIcon
/>
)}
/>
</div>
<div className="flex-1">
<h6 className="mb-2 block text-sm font-medium text-custom-text-400">Pick a time</h6>
<Controller
control={control}
name="time"
rules={{ required: "Please select a time" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="truncate">
{value ? (
<span>
{value} {watch("period").toLowerCase()}
</span>
) : (
<span className="text-sm text-custom-text-400">Select a time</span>
)}
</div>
}
input
>
<div className="mb-2 flex h-9 w-full overflow-hidden rounded">
<div
onClick={() => {
setValue("period", "AM");
}}
className={`flex h-full w-1/2 cursor-pointer items-center justify-center text-center ${
watch("period") === "AM"
? "bg-custom-primary-100/90 text-custom-primary-0"
: "bg-custom-background-80"
}`}
>
AM
</div>
<div
onClick={() => {
setValue("period", "PM");
}}
className={`flex h-full w-1/2 cursor-pointer items-center justify-center text-center ${
watch("period") === "PM"
? "bg-custom-primary-100/90 text-custom-primary-0"
: "bg-custom-background-80"
}`}
>
PM
</div>
</div>
{getTimeStamp().length > 0 ? (
getTimeStamp().map((time, index) => (
<CustomSelect.Option key={`${time}-${index}`} value={time.value}>
<div className="flex items-center">
<span className="ml-3 block truncate">{time.label}</span>
</div>
</CustomSelect.Option>
))
) : (
<p className="p-3 text-center text-custom-text-200">No available time for this date.</p>
)}
</CustomSelect>
)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="flex w-full items-center justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,155 @@
"use client";
import type { Dispatch, FC, SetStateAction } from "react";
import { Fragment } from "react";
import { observer } from "mobx-react";
import { Clock } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// plane imports
import { NOTIFICATION_SNOOZE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationSnoozeModal } from "./modal";
type TNotificationItemSnoozeOption = {
workspaceSlug: string;
notification: INotification;
setIsSnoozeStateModalOpen: Dispatch<SetStateAction<boolean>>;
customSnoozeModal: boolean;
setCustomSnoozeModal: Dispatch<SetStateAction<boolean>>;
};
export const NotificationItemSnoozeOption: FC<TNotificationItemSnoozeOption> = observer((props) => {
const { workspaceSlug, notification, setIsSnoozeStateModalOpen, customSnoozeModal, setCustomSnoozeModal } = props;
// hooks
const { isMobile } = usePlatformOS();
const {} = useWorkspaceNotifications();
const { t } = useTranslation();
const { asJson: data, snoozeNotification, unSnoozeNotification } = notification;
const handleNotificationSnoozeDate = async (snoozeTill: Date | undefined) => {
if (snoozeTill) {
try {
await snoozeNotification(workspaceSlug, snoozeTill);
setToast({
title: `${t("common.success")}!`,
message: t("notification.toasts.snoozed"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
} else {
try {
await unSnoozeNotification(workspaceSlug);
setToast({
title: `${t("common.success")}!`,
message: t("notification.toasts.un_snoozed"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
}
setCustomSnoozeModal(false);
setIsSnoozeStateModalOpen(false);
};
const handleDropdownSelect = (snoozeDate: Date | "un-snooze" | undefined) => {
if (snoozeDate === "un-snooze") {
handleNotificationSnoozeDate(undefined);
return;
}
if (snoozeDate) {
handleNotificationSnoozeDate(snoozeDate);
} else {
setCustomSnoozeModal(true);
}
};
return (
<>
<NotificationSnoozeModal
isOpen={customSnoozeModal}
onClose={() => setCustomSnoozeModal(false)}
onSubmit={handleNotificationSnoozeDate}
/>
<Popover className="relative">
{({ open }) => {
if (open) setIsSnoozeStateModalOpen(true);
else setIsSnoozeStateModalOpen(false);
return (
<>
<Tooltip
tooltipContent={
data.snoozed_till ? t("notification.options.mark_unsnooze") : t("notification.options.mark_snooze")
}
isMobile={isMobile}
>
<Popover.Button
className={cn(
"relative flex-shrink-0 w-5 h-5 rounded-sm flex justify-center items-center outline-none bg-custom-background-80 hover:bg-custom-background-90",
open ? "bg-custom-background-80" : ""
)}
>
<Clock className="h-3 w-3 text-custom-text-300" />
</Popover.Button>
</Tooltip>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute mt-2 right-0 z-10 min-w-44 select-none">
<div className="p-2 rounded-md border border-custom-border-200 bg-custom-background-100 space-y-1">
{data.snoozed_till && (
<button
className="w-full text-left cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm text-custom-text-200 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDropdownSelect("un-snooze");
}}
>
<div>{t("notification.options.mark_unsnooze")}</div>
</button>
)}
{NOTIFICATION_SNOOZE_OPTIONS.map((option) => (
<button
key={option.key}
className="w-full text-left cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm text-custom-text-200 text-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDropdownSelect(option.value != undefined ? option.value() : option.value);
}}
>
<div>{t(option?.i18n_label)}</div>
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
</>
);
});

View File

@@ -0,0 +1,118 @@
"use client";
import type { FC } from "react";
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import type { TNotificationTab } from "@plane/constants";
import { NOTIFICATION_TABS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Header, Row, ERowVariant, EHeaderVariant, ContentWrapper } from "@plane/ui";
import { cn, getNumberCount } from "@plane/utils";
// components
import { CountChip } from "@/components/common/count-chip";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { NotificationListRoot } from "@/plane-web/components/workspace-notifications/list-root";
// local imports
import { NotificationEmptyState } from "./empty-state";
import { AppliedFilters } from "./filters/applied-filter";
import { NotificationSidebarHeader } from "./header";
import { NotificationsLoader } from "./loader";
export const NotificationsSidebarRoot: FC = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { getWorkspaceBySlug } = useWorkspace();
const {
currentSelectedNotificationId,
unreadNotificationsCount,
loader,
notificationIdsByWorkspaceId,
currentNotificationTab,
setCurrentNotificationTab,
} = useWorkspaceNotifications();
const { t } = useTranslation();
// derived values
const workspace = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString()) : undefined;
const notificationIds = workspace ? notificationIdsByWorkspaceId(workspace.id) : undefined;
const handleTabClick = useCallback(
(tabValue: TNotificationTab) => {
if (currentNotificationTab !== tabValue) {
setCurrentNotificationTab(tabValue);
}
},
[currentNotificationTab, setCurrentNotificationTab]
);
if (!workspaceSlug || !workspace) return <></>;
return (
<div
className={cn(
"relative border-0 md:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all max-md:overflow-hidden",
currentSelectedNotificationId ? "w-0 md:w-2/6" : "w-full md:w-2/6"
)}
>
<div className="relative w-full h-full flex flex-col">
<Row className="h-header border-b border-custom-border-200 flex flex-shrink-0">
<NotificationSidebarHeader workspaceSlug={workspaceSlug.toString()} />
</Row>
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
{NOTIFICATION_TABS.map((tab) => (
<div
key={tab.value}
className="h-full px-3 relative cursor-pointer"
onClick={() => handleTabClick(tab.value)}
>
<div
className={cn(
`relative h-full flex justify-center items-center gap-1 text-sm transition-all`,
currentNotificationTab === tab.value
? "text-custom-primary-100"
: "text-custom-text-100 hover:text-custom-text-200"
)}
>
<div className="font-medium">{t(tab.i18n_label)}</div>
{tab.count(unreadNotificationsCount) > 0 && (
<CountChip count={getNumberCount(tab.count(unreadNotificationsCount))} />
)}
</div>
{currentNotificationTab === tab.value && (
<div className="border absolute bottom-0 right-0 left-0 rounded-t-md border-custom-primary-100" />
)}
</div>
))}
</Header>
{/* applied filters */}
<AppliedFilters workspaceSlug={workspaceSlug.toString()} />
{/* rendering notifications */}
{loader === "init-loader" ? (
<div className="relative w-full h-full overflow-hidden">
<NotificationsLoader />
</div>
) : (
<>
{notificationIds && notificationIds.length > 0 ? (
<ContentWrapper variant={ERowVariant.HUGGING}>
<NotificationListRoot workspaceSlug={workspaceSlug.toString()} workspaceId={workspace?.id} />
</ContentWrapper>
) : (
<div className="relative w-full h-full flex justify-center items-center">
<NotificationEmptyState currentNotificationTab={currentNotificationTab} />
</div>
)}
</>
)}
</div>
</div>
);
});