[WEB-1764] chore: revamp workspace notifications (#4947)

* chore: Initialised store and updated the components

* chore: updated store and types

* chore: updated notifications in the side and updated store

* chore: handled notification center

* chore: updates store request

* chore: notifications filter changed

* chore: updated filter logic and handled bulk read

* chore: handled filter dropdown

* chore: handled ui

* chore: resolved build error

* chore: implemented applied filters

* chore: removed old notifications

* chore: added redirection from sidebar

* chore: updated notification as read when we see the notification preview

* chore: updated read and unread validation

* chore: handled custom snooze dropdown

* chore: resolved git comments

* chore: updated structure and typos

* chore: import and prop changes

* chore: updated avatar props

* chore: updated avatar

* chore: notification unread count on the app sidebar

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
guru_sainath 2024-06-28 19:00:48 +05:30 committed by GitHub
parent 8d5d0422e9
commit 209dc57307
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2337 additions and 1623 deletions

View file

@ -1,20 +1,22 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { Tooltip } from "@plane/ui";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
label?: string;
label?: string | ReactNode;
href?: string;
icon?: React.ReactNode | undefined;
disableTooltip?: boolean;
};
export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
const { href, label, icon, disableTooltip = false } = props;
const { isMobile } = usePlatformOS();
return (
<Tooltip tooltipContent={label} position="bottom" isMobile={isMobile}>
<Tooltip tooltipContent={label} position="bottom" isMobile={isMobile} disabled={disableTooltip}>
<li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
@ -25,7 +27,9 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
{icon && (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
{label && (
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
)}
</Link>
) : (
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">

View file

@ -16,7 +16,7 @@ export const AppHeader = (props: AppHeaderProps) => {
<>
<div className="z-[15]">
<div className="z-10 flex w-full items-center border-b border-custom-border-200">
<div className="block bg-custom-sidebar-background-100 py-4 pl-5 md:hidden">
<div className="block bg-custom-sidebar-background-100 py-4 pl-5 md:hidden">
<SidebarHamburgerToggle />
</div>
<div className="w-full">{header}</div>

View file

@ -4,11 +4,7 @@ import { useParams } from "next/navigation";
import useSWR from "swr";
// mobx store
// components
import {
ArchivedIssueListLayout,
ArchivedIssueAppliedFiltersRoot,
IssuePeekOverview,
} from "@/components/issues";
import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot, IssuePeekOverview } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue";
// ui
import { useIssues } from "@/hooks/store";

View file

@ -54,6 +54,7 @@ export type PeekOverviewHeaderProps = {
issueId: string;
isArchived: boolean;
disabled: boolean;
embedIssue: boolean;
toggleDeleteIssueModal: (issueId: string | null) => void;
toggleArchiveIssueModal: (issueId: string | null) => void;
handleRestoreIssue: () => void;
@ -69,6 +70,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
issueId,
isArchived,
disabled,
embedIssue = false,
removeRoutePeekId,
toggleDeleteIssueModal,
toggleArchiveIssueModal,
@ -123,7 +125,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
<MoveDiagonal className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</Link>
</Tooltip>
{currentMode && (
{currentMode && embedIssue === false && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}

View file

@ -17,6 +17,7 @@ import { EUserProjectRoles } from "@/constants/project";
import { useEventTracker, useIssueDetail, useIssues, useUser } from "@/hooks/store";
interface IIssuePeekOverview {
embedIssue?: boolean;
is_archived?: boolean;
is_draft?: boolean;
}
@ -45,7 +46,7 @@ export type TIssuePeekOperations = {
};
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const { is_archived = false, is_draft = false } = props;
const { embedIssue = false, is_archived = false, is_draft = false } = props;
// router
const pathname = usePathname();
const {
@ -406,6 +407,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
isLoading={isLoading}
is_archived={is_archived}
disabled={!isEditable}
embedIssue={embedIssue}
issueOperations={issueOperations}
/>
);

View file

@ -29,11 +29,21 @@ interface IIssueView {
isLoading?: boolean;
is_archived: boolean;
disabled?: boolean;
embedIssue?: boolean;
issueOperations: TIssueOperations;
}
export const IssueView: FC<IIssueView> = observer((props) => {
const { workspaceSlug, projectId, issueId, isLoading, is_archived, disabled = false, issueOperations } = props;
const {
workspaceSlug,
projectId,
issueId,
isLoading,
is_archived,
disabled = false,
embedIssue = false,
issueOperations,
} = props;
// states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
@ -84,6 +94,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
removeRoutePeekId();
};
const peekOverviewIssueClassName = cn(
!embedIssue &&
"fixed z-20 flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300",
!embedIssue && {
"bottom-0 right-0 top-0 w-full md:w-[50%]": peekMode === "side-peek",
"size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal",
"inset-0 m-4": peekMode === "full-screen",
}
);
return (
<>
{issue && !is_archived && (
@ -113,14 +133,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{issueId && (
<div
ref={issuePeekOverviewRef}
className={cn(
"fixed z-20 flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300",
{
"bottom-0 right-0 top-0 w-full md:w-[50%]": peekMode === "side-peek",
"size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal",
"inset-0 m-4": peekMode === "full-screen",
}
)}
className={peekOverviewIssueClassName}
style={{
boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
@ -140,6 +153,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
projectId={projectId}
isSubmitting={isSubmitting}
disabled={disabled}
embedIssue={embedIssue}
/>
{/* content */}
<div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto">

View file

@ -1,4 +0,0 @@
export * from "./notification-card";
export * from "./notification-popover";
export * from "./select-snooze-till-modal";
export * from "./notification-header";

View file

@ -1,423 +0,0 @@
"use client";
import React, { useEffect, useRef } from "react";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react";
import { Menu } from "@headlessui/react";
// type
import type { IUserNotification, NotificationType } from "@plane/types";
import { ArchiveIcon, CustomMenu, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import {
ISSUE_OPENED,
NOTIFICATIONS_READ,
NOTIFICATION_ARCHIVED,
NOTIFICATION_SNOOZED,
} from "@/constants/event-tracker";
import { snoozeOptions } from "@/constants/notification";
// helper
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "@/helpers/date-time.helper";
import { sanitizeCommentForNotification } from "@/helpers/notification.helper";
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "@/helpers/string.helper";
// hooks
import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type NotificationCardProps = {
selectedTab: NotificationType;
notification: IUserNotification;
isSnoozedTabOpen: boolean;
closePopover: () => void;
markNotificationReadStatus: (notificationId: string) => Promise<void>;
markNotificationReadStatusToggle: (notificationId: string) => Promise<void>;
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
setSelectedNotificationForSnooze: (notificationId: string) => void;
markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
};
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const {
selectedTab,
notification,
isSnoozedTabOpen,
closePopover,
markNotificationReadStatus,
markNotificationReadStatusToggle,
markNotificationArchivedStatus,
setSelectedNotificationForSnooze,
markSnoozeNotification,
} = props;
// store hooks
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
const { workspaceSlug } = useParams();
// states
const [showSnoozeOptions, setShowSnoozeOptions] = React.useState(false);
// refs
const snoozeRef = useRef<HTMLDivElement | null>(null);
const moreOptions = [
{
id: 1,
name: notification.read_at ? "Mark as unread" : "Mark as read",
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => {
setToast({
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: TOAST_TYPE.SUCCESS,
});
});
},
},
{
id: 2,
name: notification.archived_at ? "Unarchive" : "Archive",
icon: notification.archived_at ? (
<ArchiveRestore className="h-3.5 w-3.5 text-custom-text-300" />
) : (
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-300" />
),
onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => {
setToast({
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
type: TOAST_TYPE.SUCCESS,
});
});
},
},
];
const snoozeOptionOnClick = (date: Date | null) => {
if (!date) {
setSelectedNotificationForSnooze(notification.id);
return;
}
markSnoozeNotification(notification.id, date).then(() => {
setToast({
title: `Notification snoozed till ${renderFormattedDate(date)}`,
type: TOAST_TYPE.SUCCESS,
});
});
};
// close snooze options on outside click
useEffect(() => {
const handleClickOutside = (event: any) => {
if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) {
setShowSnoozeOptions(false);
}
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("touchend", handleClickOutside, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("touchend", handleClickOutside, true);
};
}, []);
const notificationField = notification.data.issue_activity.field;
const notificationTriggeredBy = notification.triggered_by_details;
const snoozedTillDate = getDate(notification?.snoozed_till);
if (snoozedTillDate && isSnoozedTabOpen && snoozedTillDate < new Date()) return null;
return (
<Link
onClick={() => {
markNotificationReadStatus(notification.id);
captureEvent(ISSUE_OPENED, {
issue_id: notification.data.issue.id,
element: "notification",
});
closePopover();
}}
href={`/${workspaceSlug}/projects/${notification.project}/${
notificationField === "archived_at" ? "archives/" : ""
}issues/${notification.data.issue.id}`}
className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${
notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
}`}
>
{notification.read_at === null && (
<span className="absolute left-2 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-custom-primary-100" />
)}
<div className="relative h-12 w-12 rounded-full">
{notificationTriggeredBy.avatar && notificationTriggeredBy.avatar !== "" ? (
<div className="h-12 w-12 rounded-full">
<Image
src={notificationTriggeredBy.avatar}
alt="Profile Image"
layout="fill"
objectFit="cover"
className="rounded-full"
/>
</div>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-custom-background-80">
<span className="text-lg font-medium text-custom-text-100">
{notificationTriggeredBy.is_bot ? (
notificationTriggeredBy.first_name?.[0]?.toUpperCase()
) : notificationTriggeredBy.display_name?.[0] ? (
notificationTriggeredBy.display_name?.[0]?.toUpperCase()
) : (
<User2 className="h-4 w-4" />
)}
</span>
</div>
)}
</div>
<div className="w-full space-y-2.5 overflow-hidden">
<div className="flex items-start">
{!notification.message ? (
<div className="w-full break-all text-sm group-hover:pr-24 line-clamp-2">
<span className="font-semibold">
{notificationTriggeredBy.is_bot
? notificationTriggeredBy.first_name
: notificationTriggeredBy.display_name}{" "}
</span>
{!["comment", "archived_at"].includes(notificationField) && notification.data.issue_activity.verb}{" "}
{notificationField === "comment"
? "commented"
: notificationField === "archived_at"
? notification.data.issue_activity.new_value === "restore"
? "restored the issue"
: "archived the issue"
: notificationField === "None"
? null
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
<span className="font-semibold">
{" "}
{notificationField !== "None" ? (
notificationField !== "comment" ? (
notificationField === "target_date" ? (
renderFormattedDate(notification.data.issue_activity.new_value)
) : notificationField === "attachment" ? (
"the issue"
) : notificationField === "description" ? (
stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
) : notificationField === "archived_at" ? null : (
notification.data.issue_activity.new_value
)
) : (
<span>
{sanitizeCommentForNotification(notification.data.issue_activity.new_value ?? undefined)}
</span>
)
) : (
"the issue and assigned it to you."
)}
</span>
</div>
) : (
<div className="w-full break-words text-sm">
<span className="semi-bold">{notification.message}</span>
</div>
)}
<div className="flex items-start md:hidden">
<Menu as="div" className={" w-min text-left"}>
{({ open }) => (
<>
<Menu.Button as={React.Fragment}>
<button
onClick={(e) => e.stopPropagation()}
className="flex w-full items-center gap-x-2 rounded p-0.5 text-sm"
>
<MoreVertical className="h-3.5 w-3.5 text-custom-text-300" />
</button>
</Menu.Button>
{open && (
<Menu.Items className={"absolute right-0 z-10"} static>
<div
className={
"my-1 min-w-[12rem] overflow-y-scroll whitespace-nowrap rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
}
>
{moreOptions.map((item) => (
<Menu.Item as="div" key={item.id}>
{({ close }) => (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
item.onClick();
close();
}}
className="flex items-center gap-x-2 p-1.5"
>
{item.icon}
{item.name}
</button>
)}
</Menu.Item>
))}
<Menu.Item as="div">
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setShowSnoozeOptions(true);
}}
className="flex items-center gap-x-2 p-1.5"
>
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
Snooze
</div>
</Menu.Item>
</div>
</Menu.Items>
)}
</>
)}
</Menu>
{showSnoozeOptions && (
<div
ref={snoozeRef}
className="absolute right-36 top-24 z-20 my-1 min-w-[12rem] overflow-y-scroll whitespace-nowrap rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
>
{snoozeOptions.map((item) => (
<p
key={item.label}
className="p-1.5"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setShowSnoozeOptions(false);
snoozeOptionOnClick(item.value);
}}
>
{item.label}
</p>
))}
</div>
)}
</div>
</div>
<div className="flex justify-between gap-2 text-xs">
<p className="line-clamp-1 text-custom-text-300">
{truncateText(
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
50
)}
</p>
{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)},{" "}
{renderFormattedTime(notification.snoozed_till, "12-hour")}
</span>
</p>
) : (
<p className="mt-auto flex-shrink-0 text-custom-text-300">{calculateTimeAgo(notification.created_at)}</p>
)}
</div>
</div>
<div className="absolute right-3 top-3 hidden gap-x-3 py-1 group-hover:flex">
{[
{
id: 1,
name: notification.read_at ? "Mark as unread" : "Mark as read",
icon: <MessageSquare className="h-3.5 w-3.5 text-custom-text-300" />,
onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => {
captureEvent(NOTIFICATIONS_READ, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToast({
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: TOAST_TYPE.SUCCESS,
});
});
},
},
{
id: 2,
name: notification.archived_at ? "Unarchive" : "Archive",
icon: notification.archived_at ? (
<ArchiveRestore className="h-3.5 w-3.5 text-custom-text-300" />
) : (
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-300" />
),
onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => {
captureEvent(NOTIFICATION_ARCHIVED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToast({
title: notification.archived_at ? "Notification un-archived" : "Notification archived",
type: TOAST_TYPE.SUCCESS,
});
});
},
},
].map((item) => (
<Tooltip tooltipContent={item.name} key={item.id} isMobile={isMobile}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
item.onClick();
}}
key={item.id}
className="flex w-full items-center gap-x-2 rounded bg-custom-background-80 p-0.5 text-sm outline-none hover:bg-custom-background-100"
>
{item.icon}
</button>
</Tooltip>
))}
<CustomMenu
className="flex items-center"
customButton={
<Tooltip tooltipContent="Snooze" isMobile={isMobile}>
<div className="flex w-full items-center gap-x-2 rounded bg-custom-background-80 p-0.5 text-sm hover:bg-custom-background-100">
<Clock className="h-3.5 w-3.5 text-custom-text-300" />
</div>
</Tooltip>
}
optionsClassName="!z-20"
>
{snoozeOptions.map((item) => (
<CustomMenu.MenuItem
key={item.label}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (!item.value) {
setSelectedNotificationForSnooze(notification.id);
return;
}
markSnoozeNotification(notification.id, item.value).then(() => {
captureEvent(NOTIFICATION_SNOOZED, {
issue_id: notification.data.issue.id,
tab: selectedTab,
state: "SUCCESS",
});
setToast({
title: `Notification snoozed till ${renderFormattedDate(item.value)}`,
type: TOAST_TYPE.SUCCESS,
});
});
}}
>
{item.label}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</Link>
);
};

View file

@ -1,223 +0,0 @@
"use client";
import React from "react";
import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react";
import type { NotificationType, NotificationCount } from "@plane/types";
// components
import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
import { SidebarHamburgerToggle } from "@/components/core/sidebar";
// ui
// hooks
import {
ARCHIVED_NOTIFICATIONS,
NOTIFICATIONS_READ,
SNOOZED_NOTIFICATIONS,
UNREAD_NOTIFICATIONS,
} from "@/constants/event-tracker";
import { getNumberCount } from "@/helpers/string.helper";
import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// helpers
// type
// constants
type NotificationHeaderProps = {
notificationCount?: NotificationCount | null;
notificationMutate: () => void;
closePopover: () => void;
isRefreshing?: boolean;
snoozed: boolean;
archived: boolean;
readNotification: boolean;
selectedTab: NotificationType;
setSnoozed: React.Dispatch<React.SetStateAction<boolean>>;
setArchived: React.Dispatch<React.SetStateAction<boolean>>;
setReadNotification: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedTab: React.Dispatch<React.SetStateAction<NotificationType>>;
markAllNotificationsAsRead: () => Promise<void>;
};
export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => {
const {
notificationCount,
notificationMutate,
closePopover,
isRefreshing,
snoozed,
archived,
readNotification,
selectedTab,
setSnoozed,
setArchived,
setReadNotification,
setSelectedTab,
markAllNotificationsAsRead,
} = props;
// store hooks
const { captureEvent } = useEventTracker();
// hooks
const { isMobile } = usePlatformOS();
const notificationTabs: Array<{
label: string;
value: NotificationType;
unreadCount?: number;
}> = [
{
label: "My Issues",
value: "assigned",
unreadCount: notificationCount?.my_issues,
},
{
label: "Created by me",
value: "created",
unreadCount: notificationCount?.created_issues,
},
{
label: "Subscribed",
value: "watching",
unreadCount: notificationCount?.watching_issues,
},
];
return (
<>
<div className="flex items-center justify-between px-5 pt-5">
<div className="flex items-center gap-x-2 ">
<SidebarHamburgerToggle />
<h2 className="md:text-xl md:font-semibold">Notifications</h2>
</div>
<div className="flex items-center justify-center gap-x-4 text-custom-text-200">
<Tooltip tooltipContent="Refresh" isMobile={isMobile}>
<button
type="button"
onClick={() => {
notificationMutate();
}}
>
<RefreshCw className={`h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`} />
</button>
</Tooltip>
<Tooltip tooltipContent="Unread notifications" isMobile={isMobile}>
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification((prev) => !prev);
captureEvent(UNREAD_NOTIFICATIONS);
}}
>
<ListFilter className="h-3.5 w-3.5" />
</button>
</Tooltip>
<CustomMenu
customButton={
<div className="grid place-items-center ">
<MoreVertical className="h-3.5 w-3.5" />
</div>
}
closeOnSelect
>
<CustomMenu.MenuItem
onClick={() => {
markAllNotificationsAsRead();
captureEvent(NOTIFICATIONS_READ);
}}
>
<div className="flex items-center gap-2">
<CheckCheck className="h-3.5 w-3.5" />
Mark all as read
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setArchived(false);
setReadNotification(false);
setSnoozed((prev) => !prev);
captureEvent(SNOOZED_NOTIFICATIONS);
}}
>
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5" />
Show snoozed
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
setSnoozed(false);
setReadNotification(false);
setArchived((prev) => !prev);
captureEvent(ARCHIVED_NOTIFICATIONS);
}}
>
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3.5 w-3.5" />
Show archived
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<div className="hidden md:block">
<Tooltip tooltipContent="Close" isMobile={isMobile}>
<button type="button" onClick={() => closePopover()}>
<X className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="mt-5 w-full border-b border-custom-border-300 px-5">
{snoozed || archived || readNotification ? (
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification(false);
}}
>
<h4 className="flex items-center gap-2 pb-4">
<ArrowLeft className="h-3.5 w-3.5" />
<span className="ml-2 font-medium">
{snoozed
? "Snoozed Notifications"
: readNotification
? "Unread Notifications"
: "Archived Notifications"}
</span>
</h4>
</button>
) : (
<nav className="flex space-x-5 overflow-x-auto" aria-label="Tabs">
{notificationTabs.map((tab) => (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 px-1 pb-4 text-sm font-medium outline-none ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full px-2 py-0.5 text-xs ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
))}
</nav>
)}
</div>
</>
);
};

View file

@ -1,226 +0,0 @@
"use client";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { Bell } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// ui
import { Tooltip } from "@plane/ui";
// components
import { EmptyState } from "@/components/empty-state";
import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "@/components/notifications";
import { NotificationsLoader } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { cn } from "@/helpers/common.helper";
import { getNumberCount } from "@/helpers/string.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
import useUserNotification from "@/hooks/use-user-notifications";
export const NotificationPopover = observer(() => {
// states
const [isActive, setIsActive] = React.useState(false);
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
// refs
const notificationPopoverRef = React.useRef<HTMLDivElement | null>(null);
// hooks
const { isMobile } = usePlatformOS();
const {
notifications,
archived,
readNotification,
selectedNotificationForSnooze,
selectedTab,
setArchived,
setReadNotification,
setSelectedNotificationForSnooze,
setSelectedTab,
setSnoozed,
snoozed,
notificationMutate,
markNotificationArchivedStatus,
markNotificationReadStatus,
markNotificationAsRead,
markSnoozeNotification,
notificationCount,
totalNotificationCount,
setSize,
isLoadingMore,
hasMore,
isRefreshing,
setFetchNotifications,
markAllNotificationsAsRead,
} = useUserNotification();
const isSidebarCollapsed = sidebarCollapsed;
useOutsideClickDetector(notificationPopoverRef, () => {
// if snooze modal is open, then don't close the popover
if (selectedNotificationForSnooze === null) setIsActive(false);
});
const currentTabEmptyState = snoozed
? EmptyStateType.NOTIFICATION_SNOOZED_EMPTY_STATE
: archived
? EmptyStateType.NOTIFICATION_ARCHIVED_EMPTY_STATE
: selectedTab === "created"
? EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE
: selectedTab === "watching"
? EmptyStateType.NOTIFICATION_SUBSCRIBED_EMPTY_STATE
: EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE;
return (
<>
<SnoozeNotificationModal
isOpen={selectedNotificationForSnooze !== null}
onClose={() => setSelectedNotificationForSnooze(null)}
onSubmit={markSnoozeNotification}
notification={
notifications?.find((notification: any) => notification.id === selectedNotificationForSnooze) || null
}
onSuccess={() => setSelectedNotificationForSnooze(null)}
/>
<Popover ref={notificationPopoverRef} className="w-full md:relative">
<>
<Tooltip
tooltipContent="Notifications"
position="right"
className="ml-2"
disabled={!isSidebarCollapsed}
isMobile={isMobile}
>
<button
className={cn(
`group relative flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium leading-5 outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90"
}`,
{
"p-0 size-8 aspect-square justify-center mx-auto": isSidebarCollapsed,
}
)}
onClick={() => {
if (window.innerWidth < 768) toggleSidebar();
if (!isActive) setFetchNotifications(true);
setIsActive(!isActive);
}}
>
<Bell className="size-4" />
{isSidebarCollapsed ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? (
isSidebarCollapsed ? (
<span className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-custom-primary-300" />
) : (
<span className="ml-auto rounded-full bg-custom-primary-300 px-1.5 text-xs text-white">
{getNumberCount(totalNotificationCount)}
</span>
)
) : null}
</button>
</Tooltip>
<Transition
show={isActive}
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 left-[280px] top-0 z-10 flex h-full w-[100vw] flex-col rounded-xl border-custom-border-300 bg-custom-background-100 shadow-lg md:-top-36 md:left-full md:ml-8 md:h-[50vh] md:w-[36rem] md:border"
static
>
<NotificationHeader
notificationCount={notificationCount}
notificationMutate={notificationMutate}
closePopover={() => setIsActive(false)}
isRefreshing={isRefreshing}
snoozed={snoozed}
archived={archived}
readNotification={readNotification}
selectedTab={selectedTab}
setSnoozed={setSnoozed}
setArchived={setArchived}
setReadNotification={setReadNotification}
setSelectedTab={setSelectedTab}
markAllNotificationsAsRead={markAllNotificationsAsRead}
/>
{notifications ? (
notifications.length > 0 ? (
<div className="vertical-scrollbar scrollbar-md h-full overflow-y-auto">
<div className="divide-y divide-custom-border-100">
{notifications.map((notification: any) => (
<NotificationCard
selectedTab={selectedTab}
key={notification.id}
isSnoozedTabOpen={snoozed}
closePopover={() => setIsActive(false)}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationAsRead}
markNotificationReadStatusToggle={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))}
</div>
{isLoadingMore && (
<div className="my-6 flex items-center justify-center text-sm">
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-6 w-6 animate-spin fill-blue-600 text-custom-text-200"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
<p>Loading notifications</p>
</div>
)}
{hasMore && !isLoadingMore && (
<button
type="button"
className="my-6 flex w-full items-center justify-center text-sm font-medium text-custom-primary-100"
disabled={isLoadingMore}
onClick={() => {
setSize((prev: any) => prev + 1);
}}
>
Load More
</button>
)}
</div>
) : (
<div className="grid h-full w-full scale-75 place-items-center overflow-hidden">
<EmptyState type={currentTabEmptyState} layout="screen-simple" />
</div>
)
) : (
<NotificationsLoader />
)}
</Popover.Panel>
</Transition>
</>
</Popover>
</>
);
});

View file

@ -0,0 +1,2 @@
export * from "./notification-app-sidebar-option";
export * from "./sidebar";

View file

@ -0,0 +1,36 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// helpers
import { getNumberCount } from "@/helpers/string.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
type TNotificationAppSidebarOption = {
workspaceSlug: string;
isSidebarCollapsed: boolean | undefined;
};
export const NotificationAppSidebarOption: FC<TNotificationAppSidebarOption> = observer((props) => {
const { workspaceSlug, isSidebarCollapsed } = props;
// hooks
const { totalUnreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug) : null
);
if (totalUnreadNotificationsCount <= 0) return <></>;
if (isSidebarCollapsed)
return <div className="absolute right-3.5 top-2 h-2 w-2 rounded-full bg-custom-primary-300" />;
return (
<div className="text-[8px] ml-auto bg-custom-primary-100 text-white p-1 py-0.5 rounded-full">
{getNumberCount(totalUnreadNotificationsCount)}
</div>
);
});

View file

@ -0,0 +1,64 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { X } from "lucide-react";
// constants
import { ENotificationFilterType, FILTER_TYPE_OPTIONS } from "@/constants/notification";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
type TAppliedFilters = {
workspaceSlug: string;
};
export const AppliedFilters: FC<TAppliedFilters> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { filters, updateFilters } = useWorkspaceNotifications();
// 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 (
<div className="border-b border-custom-border-200 px-5 py-3 flex items-center flex-wrap gap-2">
{FILTER_TYPE_OPTIONS.map((filter) => {
const isSelected = filters?.type?.[filter?.value] || false;
if (!isSelected) return <></>;
return (
<div
key={filter.value}
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all border border-custom-border-200 rounded-sm text-xs"
onClick={() => handleFilterTypeChange(filter?.value, !isSelected)}
>
<div className="whitespace-nowrap text-custom-text-200">{filter.label}</div>
<div className="w-4 h-4 flex justify-center items-center transition-all rounded-sm bg-custom-background-90 hover:bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100">
<X className="h-3 w-3" />
</div>
</div>
);
})}
<div
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all border border-custom-border-200 rounded-sm text-xs bg-custom-background-90 hover:bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClearFilters}
>
<div className="whitespace-nowrap">Clear all</div>
</div>
</div>
);
});

View file

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

View file

@ -0,0 +1,80 @@
"use client";
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { Check, ListFilter } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
import { Tooltip } from "@plane/ui";
// constants
import { ENotificationFilterType, FILTER_TYPE_OPTIONS } from "@/constants/notification";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const NotificationFilter: FC = observer(() => {
// hooks
const { isMobile } = usePlatformOS();
const { filters, updateFilters } = useWorkspaceNotifications();
const handleFilterTypeChange = (filterType: ENotificationFilterType, filterValue: boolean) =>
updateFilters("type", {
...filters.type,
[filterType]: filterValue,
});
return (
<Popover className="relative">
<Tooltip tooltipContent="Notification Filters" isMobile={isMobile} position="bottom">
<Popover.Button
className={cn(
"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",
({ open }: { open: boolean }) => (open ? "bg-custom-background-80" : "")
)}
>
<ListFilter className="h-3 w-3" />
</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">
<div className="p-2 rounded-md border border-custom-border-200 bg-custom-background-100">
{FILTER_TYPE_OPTIONS.map((filter) => {
const isSelected = filters?.type?.[filter?.value] || false;
return (
<div
key={filter.value}
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => handleFilterTypeChange(filter?.value, !isSelected)}
>
<div
className={cn(
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all",
isSelected ? "bg-custom-primary-100" : "bg-custom-background-90"
)}
>
{isSelected && <Check className="h-2 w-2" />}
</div>
<div
className={cn("whitespace-nowrap", isSelected ? "text-custom-text-100" : "text-custom-text-200")}
>
{filter.label}
</div>
</div>
);
})}
</div>
</Popover.Panel>
</Transition>
</Popover>
);
});

View file

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

View file

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

View file

@ -0,0 +1,160 @@
"use client";
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { Check, CheckCheck, CheckCircle, Clock, MoreVertical } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
import { TNotificationFilter } from "@plane/types";
import { ArchiveIcon, Spinner, Tooltip } from "@plane/ui";
// constants
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
import { ENotificationLoader } from "@/constants/notification";
import { cn } from "@/helpers/common.helper";
// hooks
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TNotificationHeaderMenuOption = {
workspaceSlug: string;
};
export const NotificationHeaderMenuOption: FC<TNotificationHeaderMenuOption> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { captureEvent } = useEventTracker();
const { isMobile } = usePlatformOS();
const { loader, filters, updateFilters, updateBulkFilters, markAllNotificationsAsRead } = useWorkspaceNotifications();
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
updateFilters(filterType, filterValue);
const handleBulkFilterChange = (filter: Partial<TNotificationFilter>) => updateBulkFilters(filter);
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 (
<Popover className="relative">
<Tooltip tooltipContent="Notification Filters" isMobile={isMobile} position="bottom">
<Popover.Button
className={cn(
"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",
({ open }: { open: boolean }) => (open ? "bg-custom-background-80" : "")
)}
>
<MoreVertical className="h-3 w-3" />
</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="py-2 rounded-md border border-custom-border-200 bg-custom-background-100 space-y-1">
<div className="px-2">
<div
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => {
handleMarkAllNotificationsAsRead();
captureEvent(NOTIFICATIONS_READ);
}}
>
<CheckCheck className="h-3 w-3" />
<div>Mark all as read</div>
{loader === ENotificationLoader.MARK_ALL_AS_READY && (
<div className="ml-auto">
<Spinner height="14px" width="14px" />
</div>
)}
</div>
</div>
<div className="border-b border-custom-border-200 " />
<div className="px-2">
<div
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => handleFilterChange("read", !filters?.read)}
>
<CheckCircle className="flex-shrink-0 h-3 w-3" />
<div
className={cn("whitespace-nowrap", filters?.read ? "text-custom-text-100" : "text-custom-text-200")}
>
Show unread
</div>
{filters?.read && (
<div className="ml-auto">
<Check className="w-3 h-3" />
</div>
)}
</div>
<div
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() =>
handleBulkFilterChange({
archived: !filters?.archived,
snoozed: false,
})
}
>
<ArchiveIcon className="flex-shrink-0 h-3 w-3" />
<div
className={cn(
"whitespace-nowrap",
filters?.archived ? "text-custom-text-100" : "text-custom-text-200"
)}
>
Show Archived
</div>
{filters?.archived && (
<div className="ml-auto">
<Check className="w-3 h-3" />
</div>
)}
</div>
<div
className="flex items-center gap-2 cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() =>
handleBulkFilterChange({
snoozed: !filters?.snoozed,
archived: false,
})
}
>
<Clock className="flex-shrink-0 h-3 w-3" />
<div
className={cn(
"whitespace-nowrap",
filters?.snoozed ? "text-custom-text-100" : "text-custom-text-200"
)}
>
Show Snoozed
</div>
{filters?.snoozed && (
<div className="ml-auto">
<Check className="w-3 h-3" />
</div>
)}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
});

View file

@ -0,0 +1,51 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { RefreshCw } from "lucide-react";
import { Tooltip } from "@plane/ui";
// components
import { NotificationFilter, NotificationHeaderMenuOption } from "@/components/workspace-notifications";
// constants
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TNotificationSidebarHeaderOptions = {
workspaceSlug: string;
};
export const NotificationSidebarHeaderOptions: FC<TNotificationSidebarHeaderOptions> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { loader, getNotifications } = useWorkspaceNotifications();
const refreshNotifications = async () => {
if (loader) return;
try {
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
} catch (error) {
console.error(error);
}
};
return (
<div className="relative flex justify-center items-center gap-2 text-sm">
{/* refetch current notifications */}
<Tooltip tooltipContent="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 workspaceSlug={workspaceSlug} />
</div>
);
});

View file

@ -0,0 +1,51 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { Bell } from "lucide-react";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { SidebarHamburgerToggle } from "@/components/core";
import { NotificationSidebarHeaderOptions } from "@/components/workspace-notifications";
type TNotificationSidebarHeader = {
workspaceSlug: string;
notificationsCount: number;
};
export const NotificationSidebarHeader: FC<TNotificationSidebarHeader> = observer((props) => {
const { workspaceSlug, notificationsCount } = props;
if (!workspaceSlug) return <></>;
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="block bg-custom-sidebar-background-100 md:hidden">
<SidebarHamburgerToggle />
</div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
<div className="flex items-center gap-2">
<div className="font-medium">Notifications</div>
<div className="rounded-full text-xs px-1.5 py-0.5 bg-custom-primary-100/20">
{notificationsCount}
</div>
</div>
}
icon={<Bell className="h-4 w-4 text-custom-text-300" />}
disableTooltip
/>
}
/>
</Breadcrumbs>
</div>
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug} />
</div>
);
});

View file

@ -0,0 +1,9 @@
export * from "./loader";
export * from "./root";
export * from "./header";
export * from "./filters";
export * from "./notification-card";

View file

@ -0,0 +1,16 @@
export const NotificationsLoader = () => (
<div className="divide-y divide-custom-border-100 animate-pulse overflow-hidden">
{[...Array(3)].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,3 @@
export * from "./root";
export * from "./item";
export * from "./options";

View file

@ -0,0 +1,169 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Clock } from "lucide-react";
import { Avatar } from "@plane/ui";
// components
import { NotificationOption } from "@/components/workspace-notifications";
// helpers
import { cn } from "@/helpers/common.helper";
import { calculateTimeAgo, renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
import { sanitizeCommentForNotification } from "@/helpers/notification.helper";
import { replaceUnderscoreIfSnakeCase, stripAndTruncateHTML } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useNotification } from "@/hooks/store";
type TNotificationItem = {
workspaceSlug: string;
notificationId: string;
};
export const NotificationItem: FC<TNotificationItem> = observer((props) => {
const { workspaceSlug, notificationId } = props;
// hooks
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
// 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 notificationField = notification?.data?.issue_activity.field || undefined;
const notificationTriggeredBy = notification.triggered_by_details || undefined;
const handleNotificationIssuePeekOverview = async () => {
if (
workspaceSlug &&
projectId &&
issueId &&
!getIsIssuePeeked(issueId) &&
!isSnoozeStateModalOpen &&
!customSnoozeModal
) {
setPeekIssue({ workspaceSlug, projectId, issueId });
// make the notification as read
if (notification.read_at === null)
try {
await markNotificationAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
}
};
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField) return <></>;
return (
<div
className={cn(
"relative p-3 py-4 flex items-center gap-2 border-b border-custom-border-200 cursor-pointer transition-all group",
notification.read_at === null ? "bg-custom-primary-100/5" : ""
// peekIssue && peekIssue?.issueId === issueId ? "bg-custom-background-80" : "
)}
onClick={handleNotificationIssuePeekOverview}
>
{notification.read_at === null && (
<div className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-custom-primary-100" />
)}
<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={notificationTriggeredBy.avatar ?? undefined}
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-words truncate line-clamp-1 text-sm text-custom-text-100">
{!notification.message ? (
<>
<span className="font-semibold">
{notificationTriggeredBy?.is_bot
? notificationTriggeredBy?.first_name
: notificationTriggeredBy?.display_name}{" "}
</span>
{!["comment", "archived_at"].includes(notificationField) && notification?.data?.issue_activity.verb}{" "}
{notificationField === "comment"
? "commented"
: notificationField === "archived_at"
? notification?.data?.issue_activity.new_value === "restore"
? "restored the issue"
: "archived the issue"
: notificationField === "None"
? null
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
<span className="font-semibold">
{" "}
{notificationField !== "None" ? (
notificationField !== "comment" ? (
notificationField === "target_date" ? (
renderFormattedDate(notification?.data?.issue_activity.new_value)
) : notificationField === "attachment" ? (
"the issue"
) : notificationField === "description" ? (
stripAndTruncateHTML(notification?.data?.issue_activity.new_value || "", 55)
) : notificationField === "archived_at" ? null : (
notification?.data?.issue_activity.new_value
)
) : (
<span>
{sanitizeCommentForNotification(notification?.data?.issue_activity.new_value ?? undefined)}
</span>
)
) : (
"the issue and assigned it to you."
)}
</span>
</>
) : (
<span className="semi-bold">{notification.message}</span>
)}
</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>
</div>
);
});

View file

@ -0,0 +1,58 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { ArchiveRestore } from "lucide-react";
import { ArchiveIcon, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { NotificationItemOptionButton } from "@/components/workspace-notifications";
// constants
import { NOTIFICATION_ARCHIVED } from "@/constants/event-tracker";
// hooks
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
// store
import { INotification } from "@/store/notifications/notification";
type TNotificationItemArchiveOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemArchiveOption: FC<TNotificationItemArchiveOption> = observer((props) => {
const { workspaceSlug, notification } = props;
// hooks
const { captureEvent } = useEventTracker();
const { currentNotificationTab } = useWorkspaceNotifications();
const { asJson: data, archiveNotification, unArchiveNotification } = notification;
const handleNotificationUpdate = async () => {
try {
const request = data.archived_at ? unArchiveNotification : archiveNotification;
await request(workspaceSlug);
captureEvent(NOTIFICATION_ARCHIVED, {
issue_id: data?.data?.issue?.id,
tab: currentNotificationTab,
state: "SUCCESS",
});
setToast({
title: data.archived_at ? "Notification un-archived" : "Notification archived",
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
};
return (
<NotificationItemOptionButton
tooltipContent={data.read_at ? "Mark as unread" : "Mark as read"}
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 { FC, ReactNode } from "react";
import { Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// 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,54 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react";
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { NotificationItemOptionButton } from "@/components/workspace-notifications";
// constants
import { NOTIFICATIONS_READ } from "@/constants/event-tracker";
// hooks
import { useEventTracker, useWorkspaceNotifications } from "@/hooks/store";
// store
import { INotification } from "@/store/notifications/notification";
type TNotificationItemReadOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemReadOption: FC<TNotificationItemReadOption> = observer((props) => {
const { workspaceSlug, notification } = props;
// hooks
const { captureEvent } = useEventTracker();
const { currentNotificationTab } = useWorkspaceNotifications();
const { asJson: data, markNotificationAsRead, markNotificationAsUnRead } = notification;
const handleNotificationUpdate = async () => {
try {
const request = data.read_at ? markNotificationAsUnRead : markNotificationAsRead;
await request(workspaceSlug);
captureEvent(NOTIFICATIONS_READ, {
issue_id: data?.data?.issue?.id,
tab: currentNotificationTab,
state: "SUCCESS",
});
setToast({
title: data.read_at ? "Notification marked as unread" : "Notification marked as read",
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
};
return (
<NotificationItemOptionButton
tooltipContent={data.read_at ? "Mark as unread" : "Mark as read"}
callBack={handleNotificationUpdate}
>
<MessageSquare className="h-3 w-3 text-custom-text-300" />
</NotificationItemOptionButton>
);
});

View file

@ -0,0 +1,57 @@
"use client";
import { FC, Dispatch, SetStateAction } from "react";
import { observer } from "mobx-react";
// components
import {
NotificationItemReadOption,
NotificationItemArchiveOption,
NotificationItemSnoozeOption,
} from "@/components/workspace-notifications";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useNotification } from "@/hooks/store";
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

@ -5,40 +5,37 @@ import { useParams } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { X } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react";
import type { IUserNotification } from "@plane/types";
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { TNotification } from "@plane/types";
import { Button, CustomSelect } from "@plane/ui";
// components
import { DateDropdown } from "@/components/dropdowns";
// constants
import { allTimeIn30MinutesInterval12HoursFormat } from "@/constants/notification";
// ui
// types
// helpers
import { getDate } from "helpers/date-time.helper";
import { getDate } from "@/helpers/date-time.helper";
type SnoozeModalProps = {
type TNotificationSnoozeModal = {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
notification: IUserNotification | null;
onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
onSubmit: (dateTime?: Date | undefined) => Promise<TNotification | undefined>;
};
type FormValues = {
time: string | null;
date: Date | null;
time: string | undefined;
date: Date | undefined;
period: "AM" | "PM";
};
const defaultValues: FormValues = {
time: null,
date: null,
time: undefined,
date: undefined,
period: "AM",
};
const timeStamps = allTimeIn30MinutesInterval12HoursFormat;
export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props;
export const NotificationSnoozeModal: FC<TNotificationSnoozeModal> = (props) => {
const { isOpen, onClose, onSubmit: handleSubmitSnooze } = props;
const { workspaceSlug } = useParams();
@ -53,6 +50,19 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
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");
@ -82,7 +92,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !notification || !formData.date || !formData.time) return;
if (!workspaceSlug || !formData.date || !formData.time) return;
const period = formData.period;
@ -96,30 +106,11 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
dateTime?.setHours(hours);
dateTime?.setMinutes(minutes);
await handleSubmitSnooze(notification.id, dateTime).then(() => {
await handleSubmitSnooze(dateTime).then(() => {
handleClose();
onSuccess();
setToast({
title: "Success!",
message: "Notification snoozed successfully",
type: TOAST_TYPE.SUCCESS,
});
});
};
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);
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@ -169,10 +160,10 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
rules={{ required: "Please select a date" }}
render={({ field: { value, onChange } }) => (
<DateDropdown
value={value}
value={value || null}
placeholder="Select date"
onChange={(val) => {
setValue("time", null);
setValue("time", undefined);
onChange(val);
}}
minDate={new Date()}

View file

@ -0,0 +1,148 @@
"use client";
import { Dispatch, FC, Fragment, SetStateAction } from "react";
import { observer } from "mobx-react";
import { Clock } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
import { Tooltip, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { NotificationSnoozeModal } from "@/components/workspace-notifications";
// constants
import { NOTIFICATION_SNOOZE_OPTIONS } from "@/constants/notification";
import { cn } from "@/helpers/common.helper";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// store
import { INotification } from "@/store/notifications/notification";
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 { asJson: data, snoozeNotification, unSnoozeNotification } = notification;
const handleNotificationSnoozeDate = async (snoozeTill: Date | undefined) => {
if (snoozeTill) {
try {
const response = await snoozeNotification(workspaceSlug, snoozeTill);
setToast({
title: "Success!",
message: "Notification snoozed successfully",
type: TOAST_TYPE.SUCCESS,
});
return response;
} catch (e) {
console.error(e);
}
} else {
try {
const response = await unSnoozeNotification(workspaceSlug);
setToast({
title: "Success!",
message: "Notification un snoozed successfully",
type: TOAST_TYPE.SUCCESS,
});
return response;
} 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 ? `Un snooze` : `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>Un snooze</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>{option?.label}</div>
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
</>
);
});

View file

@ -0,0 +1,56 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
// components
import { NotificationItem } from "@/components/workspace-notifications";
// constants
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store";
type TNotificationCardListRoot = {
workspaceSlug: string;
workspaceId: string;
};
export const NotificationCardListRoot: FC<TNotificationCardListRoot> = observer((props) => {
const { workspaceSlug, workspaceId } = props;
// hooks
const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications();
const notificationIds = notificationIdsByWorkspaceId(workspaceId);
const getNextNotifications = async () => {
try {
await getNotifications(workspaceSlug, ENotificationLoader.PAGINATION_LOADER, ENotificationQueryParamType.NEXT);
} catch (error) {
console.error(error);
}
};
if (!workspaceSlug || !workspaceId || !notificationIds) return <></>;
return (
<div>
{notificationIds.map((notificationId: string) => (
<NotificationItem key={notificationId} workspaceSlug={workspaceSlug} notificationId={notificationId} />
))}
{/* fetch next page notifications */}
{paginationInfo && paginationInfo?.next_page_results && (
<>
{loader === ENotificationLoader.PAGINATION_LOADER ? (
<div className="py-4 flex justify-center items-center text-sm font-medium">
<div className="text-custom-primary-90">Loading...</div>
</div>
) : (
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={getNextNotifications}>
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">
Load more
</div>
</div>
)}
</>
)}
</div>
);
});

View file

@ -0,0 +1,70 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { EmptyState } from "@/components/empty-state";
import {
NotificationSidebarHeader,
AppliedFilters,
NotificationsLoader,
NotificationCardListRoot,
} from "@/components/workspace-notifications";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { ENotificationTab } from "@/constants/notification";
// hooks
import { useWorkspace, useWorkspaceNotifications } from "@/hooks/store";
export const NotificationsSidebar: FC = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { getWorkspaceBySlug } = useWorkspace();
const { paginationInfo, loader, notificationIdsByWorkspaceId } = useWorkspaceNotifications();
// derived values
const workspace = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString()) : undefined;
const notificationIds = workspace ? notificationIdsByWorkspaceId(workspace.id) : undefined;
// derived values
const currentTabEmptyState = ENotificationTab.ALL
? EmptyStateType.NOTIFICATION_ALL_EMPTY_STATE
: EmptyStateType.NOTIFICATION_MENTIONS_EMPTY_STATE;
const totalNotificationCount = paginationInfo?.total_count || 0;
if (!workspaceSlug || !workspace) return <></>;
return (
<div className="relative w-full h-full overflow-hidden flex flex-col">
<div className="border-b border-custom-border-200">
<NotificationSidebarHeader
workspaceSlug={workspaceSlug.toString()}
notificationsCount={totalNotificationCount}
/>
</div>
{/* applied filters */}
<div className="flex-shrink-0">
<AppliedFilters workspaceSlug={workspaceSlug.toString()} />
</div>
{/* rendering notifications */}
{loader === "init-loader" ? (
<div className="relative w-full h-full overflow-hidden p-5">
<NotificationsLoader />
</div>
) : (
<>
{notificationIds && notificationIds.length > 0 ? (
<div className="relative w-full h-full overflow-hidden overflow-y-auto">
<NotificationCardListRoot workspaceSlug={workspaceSlug.toString()} workspaceId={workspace?.id} />
</div>
) : (
<div className="relative w-full h-full flex justify-center items-center">
<EmptyState type={currentTabEmptyState} layout="screen-simple" />
</div>
)}
</>
)}
</div>
);
});

View file

@ -7,7 +7,7 @@ import { useParams, usePathname } from "next/navigation";
// ui
import { Tooltip } from "@plane/ui";
// components
import { NotificationPopover } from "@/components/notifications";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
// constants
import { SIDEBAR_USER_MENU_ITEMS } from "@/constants/dashboard";
import { SIDEBAR_CLICKED } from "@/constants/event-tracker";
@ -61,7 +61,7 @@ export const SidebarUserMenu = observer(() => {
>
<div
className={cn(
"group w-full flex items-center gap-1.5 rounded-md px-2 py-1.5 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 focus:bg-custom-sidebar-background-90",
"relative group w-full flex items-center gap-1.5 rounded-md px-2 py-1.5 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 focus:bg-custom-sidebar-background-90",
{
"text-custom-primary-100 bg-custom-primary-100/10 hover:bg-custom-primary-100/10": link.highlight(
pathname,
@ -75,12 +75,17 @@ export const SidebarUserMenu = observer(() => {
<link.Icon className="size-4" />
</span>
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>}
{link.key === "notifications" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
</div>
</Tooltip>
</Link>
)
)}
<NotificationPopover />
</div>
);
});