[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

@ -43,8 +43,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Get query parameters # Get query parameters
snoozed = request.GET.get("snoozed", "false") snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false") archived = request.GET.get("archived", "false")
read = request.GET.get("read", "true") read = request.GET.get("read", None)
type = request.GET.get("type", "all") type = request.GET.get("type", "all")
q_filters = Q()
notifications = ( notifications = (
Notification.objects.filter( Notification.objects.filter(
@ -74,8 +75,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
if read == "false": if read == "false":
notifications = notifications.filter(read_at__isnull=True) notifications = notifications.filter(read_at__isnull=True)
if read == "true":
notifications = notifications.filter(read_at__isnull=False)
type = type.split(",")
# Subscribed issues # Subscribed issues
if type == "watching": if "subscribed" in type:
issue_ids = ( issue_ids = (
IssueSubscriber.objects.filter( IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id workspace__slug=slug, subscriber_id=request.user.id
@ -97,35 +102,32 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.filter(created=False, assigned=False) .filter(created=False, assigned=False)
.values_list("issue_id", flat=True) .values_list("issue_id", flat=True)
) )
notifications = notifications.filter( q_filters |= Q(entity_identifier__in=issue_ids)
entity_identifier__in=issue_ids,
)
# Assigned Issues # Assigned Issues
if type == "assigned": if "assigned" in type:
issue_ids = IssueAssignee.objects.filter( issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True) ).values_list("issue_id", flat=True)
notifications = notifications.filter( q_filters |= Q(entity_identifier__in=issue_ids)
entity_identifier__in=issue_ids
)
# Created issues # Created issues
if type == "created": if "created" in type:
if WorkspaceMember.objects.filter( if WorkspaceMember.objects.filter(
workspace__slug=slug, workspace__slug=slug,
member=request.user, member=request.user,
role__lt=15, role__lt=15,
is_active=True, is_active=True,
).exists(): ).exists():
notifications = Notification.objects.none() notifications = notifications.none()
else: else:
issue_ids = Issue.objects.filter( issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True) ).values_list("pk", flat=True)
notifications = notifications.filter( q_filters |= Q(entity_identifier__in=issue_ids)
entity_identifier__in=issue_ids
) # Apply the combined Q object filters
notifications = notifications.filter(q_filters)
# Pagination # Pagination
if request.GET.get("per_page", False) and request.GET.get( if request.GET.get("per_page", False) and request.GET.get(
@ -200,11 +202,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView): class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
# Watching Issues Count # Watching Issues Count
watching_issues_count = Notification.objects.filter( subscribed_issues_count = Notification.objects.filter(
workspace__slug=slug, workspace__slug=slug,
receiver_id=request.user.id, receiver_id=request.user.id,
read_at__isnull=True, read_at__isnull=True,
archived_at__isnull=True, archived_at__isnull=True,
snoozed_till__isnull=True,
entity_identifier__in=IssueSubscriber.objects.filter( entity_identifier__in=IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True), ).values_list("issue_id", flat=True),
@ -216,6 +219,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
receiver_id=request.user.id, receiver_id=request.user.id,
read_at__isnull=True, read_at__isnull=True,
archived_at__isnull=True, archived_at__isnull=True,
snoozed_till__isnull=True,
entity_identifier__in=IssueAssignee.objects.filter( entity_identifier__in=IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True), ).values_list("issue_id", flat=True),
@ -227,6 +231,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
receiver_id=request.user.id, receiver_id=request.user.id,
read_at__isnull=True, read_at__isnull=True,
archived_at__isnull=True, archived_at__isnull=True,
snoozed_till__isnull=True,
entity_identifier__in=Issue.objects.filter( entity_identifier__in=Issue.objects.filter(
workspace__slug=slug, created_by=request.user workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True), ).values_list("pk", flat=True),
@ -234,7 +239,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
return Response( return Response(
{ {
"watching_issues": watching_issues_count, "subscribed_issues": subscribed_issues_count,
"my_issues": my_issues_count, "my_issues": my_issues_count,
"created_issues": created_issues_count, "created_issues": created_issues_count,
}, },

View file

@ -37,3 +37,10 @@ export enum EEstimateUpdateStages {
EDIT = "edit", EDIT = "edit",
SWITCH = "switch", SWITCH = "switch",
} }
// workspace notifications
export enum ENotificationFilterType {
CREATED = "created",
ASSIGNED = "assigned",
SUBSCRIBED = "subscribed",
}

View file

@ -19,7 +19,6 @@ export * from "./auth";
export * from "./calendar"; export * from "./calendar";
export * from "./instance"; export * from "./instance";
export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./notifications";
export * from "./reaction"; export * from "./reaction";
export * from "./view-props"; export * from "./view-props";
export * from "./waitlist"; export * from "./waitlist";
@ -28,3 +27,4 @@ export * from "./workspace-views";
export * from "./common"; export * from "./common";
export * from "./pragmatic"; export * from "./pragmatic";
export * from "./publish"; export * from "./publish";
export * from "./workspace-notifications";

View file

@ -1,79 +0,0 @@
import type { IUserLite } from "./users";
export interface PaginatedUserNotification {
next_cursor: string;
prev_cursor: string;
next_page_results: boolean;
prev_page_results: boolean;
count: number;
total_pages: number;
extra_stats: null;
results: IUserNotification[];
}
export interface IUserNotification {
archived_at: string | null;
created_at: string;
created_by: null;
data: Data;
entity_identifier: string;
entity_name: string;
id: string;
message: null;
message_html: string;
message_stripped: null;
project: string;
read_at: Date | null;
receiver: string;
sender: string;
snoozed_till: Date | null;
title: string;
triggered_by: string;
triggered_by_details: IUserLite;
updated_at: Date;
updated_by: null;
workspace: string;
}
export interface Data {
issue: INotificationIssueLite;
issue_activity: {
actor: string;
field: string;
id: string;
issue_comment: string | null;
new_value: string;
old_value: string;
verb: "created" | "updated";
};
}
export interface INotificationIssueLite {
id: string;
name: string;
identifier: string;
state_name: string;
sequence_id: number;
state_group: string;
}
export type NotificationType = "created" | "assigned" | "watching" | "all";
export interface INotificationParams {
snoozed?: boolean;
type?: NotificationType;
archived?: boolean;
read?: boolean;
}
export type NotificationCount = {
created_issues: number;
my_issues: number;
watching_issues: number;
};
export interface IMarkAllAsReadPayload {
archived?: boolean;
snoozed?: boolean;
type?: NotificationType;
}

View file

@ -0,0 +1,90 @@
import type { IUserLite } from "./users";
import { ENotificationFilterType } from "./enums";
// filters
export type TNotificationFilter = {
type: {
[key in ENotificationFilterType]: boolean;
};
snoozed: boolean;
archived: boolean;
read: boolean;
};
// notification payload
export type TNotificationIssueLite = {
id: string | undefined;
sequence_id: number | undefined;
identifier: string | undefined;
name: string | undefined;
state_name: string | undefined;
state_group: string | undefined;
};
export type TNotificationData = {
issue: TNotificationIssueLite | undefined;
issue_activity: {
id: string | undefined;
actor: string | undefined;
field: string | undefined;
issue_comment: string | undefined;
verb: "created" | "updated";
new_value: string | undefined;
old_value: string | undefined;
};
};
export type TNotification = {
id: string | undefined;
title: string | undefined;
data: TNotificationData | undefined;
entity_identifier: string | undefined;
entity_name: string | undefined;
message_html: string | undefined;
message: undefined;
message_stripped: undefined;
sender: string | undefined;
receiver: string | undefined;
triggered_by: string | undefined;
triggered_by_details: IUserLite | undefined;
read_at: string | undefined;
archived_at: string | undefined;
snoozed_till: string | undefined;
workspace: string | undefined;
project: string | undefined;
created_at: string | undefined;
updated_at: string | undefined;
created_by: string | undefined;
updated_by: string | undefined;
};
// notification paginated information
export type TNotificationPaginatedInfoQueryParams = {
type?: string | undefined;
snoozed?: boolean;
archived?: boolean;
read?: boolean;
per_page?: number;
cursor?: string;
};
export type TNotificationPaginatedInfo = {
next_cursor: string | undefined;
prev_cursor: string | undefined;
next_page_results: boolean | undefined;
prev_page_results: boolean | undefined;
total_pages: number | undefined;
extra_stats: string | undefined;
count: number | undefined; // current paginated results count
total_count: number | undefined; // total available results count
results: TNotification[] | undefined;
grouped_by: string | undefined;
sub_grouped_by: string | undefined;
};
// notification count
export type TUnreadNotificationsCount = {
created_issues: number | undefined;
my_issues: number | undefined;
subscribed_issues: number | undefined;
};

View file

@ -0,0 +1,15 @@
"use client";
// components
import { NotificationsSidebar } from "@/components/workspace-notifications";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative w-full h-full overflow-hidden flex items-center">
<div className="relative w-full lg:w-2/6 border-0 lg:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all overflow-hidden">
<NotificationsSidebar />
</div>
<div className="w-full h-full overflow-hidden overflow-y-auto">{children}</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
"use client";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { PageHead } from "@/components/core";
import { IssuePeekOverview } from "@/components/issues";
// constants
import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification";
// hooks
import { useWorkspace, useWorkspaceNotifications } from "@/hooks/store";
const WorkspaceDashboardPage = observer(() => {
// hooks
const { currentWorkspace } = useWorkspace();
const { notificationIdsByWorkspaceId, getNotifications } = useWorkspaceNotifications();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Notifications` : undefined;
// 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` : null,
currentWorkspace?.slug
? async () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader)
: null
);
return (
<>
<PageHead title={pageTitle} />
<div className="w-ful h-full overflow-hidden overflow-y-auto">
<IssuePeekOverview embedIssue />
</div>
</>
);
});
export default WorkspaceDashboardPage;

View file

@ -1,20 +1,22 @@
"use client"; "use client";
import { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
label?: string; label?: string | ReactNode;
href?: string; href?: string;
icon?: React.ReactNode | undefined; icon?: React.ReactNode | undefined;
disableTooltip?: boolean;
}; };
export const BreadcrumbLink: React.FC<Props> = (props) => { export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props; const { href, label, icon, disableTooltip = false } = props;
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
return ( 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}> <li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{href ? ( {href ? (
@ -25,7 +27,9 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
{icon && ( {icon && (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div> <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> </Link>
) : ( ) : (
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100"> <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-[15]">
<div className="z-10 flex w-full items-center border-b border-custom-border-200"> <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 /> <SidebarHamburgerToggle />
</div> </div>
<div className="w-full">{header}</div> <div className="w-full">{header}</div>

View file

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

View file

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

View file

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

View file

@ -29,11 +29,21 @@ interface IIssueView {
isLoading?: boolean; isLoading?: boolean;
is_archived: boolean; is_archived: boolean;
disabled?: boolean; disabled?: boolean;
embedIssue?: boolean;
issueOperations: TIssueOperations; issueOperations: TIssueOperations;
} }
export const IssueView: FC<IIssueView> = observer((props) => { 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 // states
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek"); const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
@ -84,6 +94,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
removeRoutePeekId(); 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 ( return (
<> <>
{issue && !is_archived && ( {issue && !is_archived && (
@ -113,14 +133,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{issueId && ( {issueId && (
<div <div
ref={issuePeekOverviewRef} ref={issuePeekOverviewRef}
className={cn( className={peekOverviewIssueClassName}
"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",
}
)}
style={{ style={{
boxShadow: 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)", "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} projectId={projectId}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
disabled={disabled} disabled={disabled}
embedIssue={embedIssue}
/> />
{/* content */} {/* content */}
<div className="vertical-scrollbar scrollbar-md relative h-full w-full overflow-hidden overflow-y-auto"> <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 { useForm, Controller } from "react-hook-form";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Transition, Dialog } from "@headlessui/react"; import { Transition, Dialog } from "@headlessui/react";
import type { IUserNotification } from "@plane/types"; import { TNotification } from "@plane/types";
import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, CustomSelect } from "@plane/ui";
// components
import { DateDropdown } from "@/components/dropdowns"; import { DateDropdown } from "@/components/dropdowns";
// constants // constants
import { allTimeIn30MinutesInterval12HoursFormat } from "@/constants/notification"; import { allTimeIn30MinutesInterval12HoursFormat } from "@/constants/notification";
// ui
// types
// helpers // helpers
import { getDate } from "helpers/date-time.helper"; import { getDate } from "@/helpers/date-time.helper";
type SnoozeModalProps = { type TNotificationSnoozeModal = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSubmit: (dateTime?: Date | undefined) => Promise<TNotification | undefined>;
notification: IUserNotification | null;
onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
}; };
type FormValues = { type FormValues = {
time: string | null; time: string | undefined;
date: Date | null; date: Date | undefined;
period: "AM" | "PM"; period: "AM" | "PM";
}; };
const defaultValues: FormValues = { const defaultValues: FormValues = {
time: null, time: undefined,
date: null, date: undefined,
period: "AM", period: "AM",
}; };
const timeStamps = allTimeIn30MinutesInterval12HoursFormat; const timeStamps = allTimeIn30MinutesInterval12HoursFormat;
export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => { export const NotificationSnoozeModal: FC<TNotificationSnoozeModal> = (props) => {
const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props; const { isOpen, onClose, onSubmit: handleSubmitSnooze } = props;
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
@ -53,6 +50,19 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
defaultValues, 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 getTimeStamp = () => {
const today = new Date(); const today = new Date();
const formDataDate = watch("date"); const formDataDate = watch("date");
@ -82,7 +92,7 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
}; };
const onSubmit = async (formData: FormValues) => { const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !notification || !formData.date || !formData.time) return; if (!workspaceSlug || !formData.date || !formData.time) return;
const period = formData.period; const period = formData.period;
@ -96,30 +106,11 @@ export const SnoozeNotificationModal: FC<SnoozeModalProps> = (props) => {
dateTime?.setHours(hours); dateTime?.setHours(hours);
dateTime?.setMinutes(minutes); dateTime?.setMinutes(minutes);
await handleSubmitSnooze(notification.id, dateTime).then(() => { await handleSubmitSnooze(dateTime).then(() => {
handleClose(); 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 ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <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" }} rules={{ required: "Please select a date" }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<DateDropdown <DateDropdown
value={value} value={value || null}
placeholder="Select date" placeholder="Select date"
onChange={(val) => { onChange={(val) => {
setValue("time", null); setValue("time", undefined);
onChange(val); onChange(val);
}} }}
minDate={new Date()} 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 // ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// components // components
import { NotificationPopover } from "@/components/notifications"; import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
// constants // constants
import { SIDEBAR_USER_MENU_ITEMS } from "@/constants/dashboard"; import { SIDEBAR_USER_MENU_ITEMS } from "@/constants/dashboard";
import { SIDEBAR_CLICKED } from "@/constants/event-tracker"; import { SIDEBAR_CLICKED } from "@/constants/event-tracker";
@ -61,7 +61,7 @@ export const SidebarUserMenu = observer(() => {
> >
<div <div
className={cn( 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( "text-custom-primary-100 bg-custom-primary-100/10 hover:bg-custom-primary-100/10": link.highlight(
pathname, pathname,
@ -75,12 +75,17 @@ export const SidebarUserMenu = observer(() => {
<link.Icon className="size-4" /> <link.Icon className="size-4" />
</span> </span>
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>} {!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{link.label}</p>}
{link.key === "notifications" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
</div> </div>
</Tooltip> </Tooltip>
</Link> </Link>
) )
)} )}
<NotificationPopover />
</div> </div>
); );
}); });

View file

@ -2,7 +2,7 @@
import { linearGradientDef } from "@nivo/core"; import { linearGradientDef } from "@nivo/core";
// icons // icons
import { BarChart2, Briefcase, CheckCircle, Home, Settings } from "lucide-react"; import { BarChart2, Bell, Briefcase, CheckCircle, Home, Settings } from "lucide-react";
// types // types
import { TIssuesListTypes, TStateGroups } from "@plane/types"; import { TIssuesListTypes, TStateGroups } from "@plane/types";
// ui // ui
@ -317,4 +317,12 @@ export const SIDEBAR_USER_MENU_ITEMS: {
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`,
Icon: Home, Icon: Home,
}, },
{
key: "notifications",
label: "Notifications",
href: `/notifications`,
access: EUserWorkspaceRoles.GUEST,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/notifications`,
Icon: Bell,
},
]; ];

View file

@ -80,6 +80,8 @@ export enum EmptyStateType {
ISSUE_RELATION_EMPTY_STATE = "issue-relation-empty-state", ISSUE_RELATION_EMPTY_STATE = "issue-relation-empty-state",
ISSUE_COMMENT_EMPTY_STATE = "issue-comment-empty-state", ISSUE_COMMENT_EMPTY_STATE = "issue-comment-empty-state",
NOTIFICATION_ALL_EMPTY_STATE = "notification-all-empty-state",
NOTIFICATION_MENTIONS_EMPTY_STATE = "notification-mentions-empty-state",
NOTIFICATION_MY_ISSUE_EMPTY_STATE = "notification-my-issues-empty-state", NOTIFICATION_MY_ISSUE_EMPTY_STATE = "notification-my-issues-empty-state",
NOTIFICATION_CREATED_EMPTY_STATE = "notification-created-empty-state", NOTIFICATION_CREATED_EMPTY_STATE = "notification-created-empty-state",
NOTIFICATION_SUBSCRIBED_EMPTY_STATE = "notification-subscribed-empty-state", NOTIFICATION_SUBSCRIBED_EMPTY_STATE = "notification-subscribed-empty-state",
@ -593,13 +595,24 @@ const emptyStateDetails = {
path: "/empty-state/search/comments", path: "/empty-state/search/comments",
}, },
[EmptyStateType.NOTIFICATION_ALL_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_ALL_EMPTY_STATE,
title: "No issues assigned",
description: "Updates for issues assigned to you can be \n seen here",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_MENTIONS_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_MENTIONS_EMPTY_STATE,
title: "No issues assigned",
description: "Updates for issues assigned to you can be \n seen here",
path: "/empty-state/search/notification",
},
[EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE]: { [EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE, key: EmptyStateType.NOTIFICATION_MY_ISSUE_EMPTY_STATE,
title: "No issues assigned", title: "No issues assigned",
description: "Updates for issues assigned to you can be \n seen here", description: "Updates for issues assigned to you can be \n seen here",
path: "/empty-state/search/notification", path: "/empty-state/search/notification",
}, },
[EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE]: { [EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE]: {
key: EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE, key: EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE,
title: "No updates to issues", title: "No updates to issues",
@ -630,6 +643,7 @@ const emptyStateDetails = {
description: "Any notification you archive will be \n available here to help you focus", description: "Any notification you archive will be \n available here to help you focus",
path: "/empty-state/search/archive", path: "/empty-state/search/archive",
}, },
[EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE]: { [EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE]: {
key: EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE, key: EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE,
title: "Add issues to the cycle to view it's \n progress", title: "Add issues to the cycle to view it's \n progress",

View file

@ -1,5 +1,4 @@
import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "@plane/types"; import { IAnalyticsParams, IJiraMetadata } from "@plane/types";
import { objToQueryParams } from "@/helpers/string.helper";
const paramsToKey = (params: any) => { const paramsToKey = (params: any) => {
const { const {
@ -246,41 +245,6 @@ export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) => export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${params?.cycle}_${params?.module}`; `DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${params?.cycle}_${params?.module}`;
// notifications
export const USER_WORKSPACE_NOTIFICATIONS = (workspaceSlug: string, params: INotificationParams) => {
const { type, snoozed, archived, read } = params;
return `USER_WORKSPACE_NOTIFICATIONS_${workspaceSlug?.toUpperCase()}_TYPE_${(
type ?? "assigned"
)?.toUpperCase()}_SNOOZED_${snoozed}_ARCHIVED_${archived}_READ_${read}`;
};
export const USER_WORKSPACE_NOTIFICATIONS_DETAILS = (workspaceSlug: string, notificationId: string) =>
`USER_WORKSPACE_NOTIFICATIONS_DETAILS_${workspaceSlug?.toUpperCase()}_${notificationId?.toUpperCase()}`;
export const UNREAD_NOTIFICATIONS_COUNT = (workspaceSlug: string) =>
`UNREAD_NOTIFICATIONS_COUNT_${workspaceSlug?.toUpperCase()}`;
export const getPaginatedNotificationKey = (index: number, prevData: any, workspaceSlug: string, params: any) => {
if (prevData && !prevData?.results?.length) return null;
if (index === 0)
return `/api/workspaces/${workspaceSlug}/users/notifications?${objToQueryParams({
...params,
cursor: "30:0:0",
})}`;
const cursor = prevData?.next_cursor;
const nextPageResults = prevData?.next_page_results;
if (!nextPageResults) return null;
return `/api/workspaces/${workspaceSlug}/users/notifications?${objToQueryParams({
...params,
cursor,
})}`;
};
// profile // profile
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) => export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`; `USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;

View file

@ -1,27 +1,101 @@
export const snoozeOptions = [ export enum ENotificationTab {
ALL = "all",
MENTIONS = "mentions",
}
export enum ENotificationFilterType {
CREATED = "created",
ASSIGNED = "assigned",
SUBSCRIBED = "subscribed",
}
export enum ENotificationLoader {
INIT_LOADER = "init-loader",
MUTATION_LOADER = "mutation-loader",
PAGINATION_LOADER = "pagination-loader",
REFRESH = "refresh",
MARK_ALL_AS_READY = "mark-all-as-read",
}
export enum ENotificationQueryParamType {
INIT = "init",
CURRENT = "current",
NEXT = "next",
}
export type TNotificationTab = ENotificationTab.ALL | ENotificationTab.MENTIONS;
export const NOTIFICATION_TABS = [
{ {
label: "All",
value: ENotificationTab.ALL,
},
// {
// label: "Mentions",
// value: ENotificationTab.MENTIONS,
// },
];
export const FILTER_TYPE_OPTIONS = [
{
label: "Assigned to me",
value: ENotificationFilterType.ASSIGNED,
},
{
label: "Created by me",
value: ENotificationFilterType.CREATED,
},
{
label: "Subscribed by me",
value: ENotificationFilterType.SUBSCRIBED,
},
];
export const NOTIFICATION_SNOOZE_OPTIONS = [
{
key: "1_day",
label: "1 day", label: "1 day",
value: new Date(new Date().getTime() + 24 * 60 * 60 * 1000), value: () => {
const date = new Date();
return new Date(date.getTime() + 24 * 60 * 60 * 1000);
},
}, },
{ {
key: "3_days",
label: "3 days", label: "3 days",
value: new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000), value: () => {
const date = new Date();
return new Date(date.getTime() + 3 * 24 * 60 * 60 * 1000);
},
}, },
{ {
key: "5_days",
label: "5 days", label: "5 days",
value: new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000), value: () => {
const date = new Date();
return new Date(date.getTime() + 5 * 24 * 60 * 60 * 1000);
},
}, },
{ {
key: "1_week",
label: "1 week", label: "1 week",
value: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), value: () => {
const date = new Date();
return new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000);
},
}, },
{ {
key: "2_weeks",
label: "2 weeks", label: "2 weeks",
value: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000), value: () => {
const date = new Date();
return new Date(date.getTime() + 14 * 24 * 60 * 60 * 1000);
},
}, },
{ {
key: "custom",
label: "Custom", label: "Custom",
value: null, value: undefined,
}, },
]; ];

View file

@ -33,3 +33,4 @@ export * from "./use-app-theme";
export * from "./use-command-palette"; export * from "./use-command-palette";
export * from "./use-router-params"; export * from "./use-router-params";
export * from "./estimates"; export * from "./estimates";
export * from "./notifications";

View file

@ -0,0 +1,2 @@
export * from "./use-workspace-notifications";
export * from "./use-notification";

View file

@ -0,0 +1,13 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// mobx store
import { INotification } from "@/store/notifications/notification";
export const useNotification = (notificationId: string | undefined): INotification => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useNotification must be used within StoreProvider");
if (!notificationId) return {} as INotification;
return context.workspaceNotification.notifications?.[notificationId] ?? {};
};

View file

@ -0,0 +1,12 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// mobx store
import { IWorkspaceNotificationStore } from "@/store/notifications/workspace-notifications.store";
export const useWorkspaceNotifications = (): IWorkspaceNotificationStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useWorkspaceNotifications must be used within StoreProvider");
return context.workspaceNotification;
};

View file

@ -1,75 +0,0 @@
import { useCallback } from "react";
import useSWR from "swr";
import { IUser, TUserProfile } from "@plane/types";
// services
import { NotificationService } from "@/services/notification.service";
// types
const userNotificationServices = new NotificationService();
const useUserIssueNotificationSubscription = (
user: IUser | null,
profile: TUserProfile | undefined,
workspaceSlug?: string | string[] | null,
projectId?: string | string[] | null,
issueId?: string | string[] | null
) => {
const { data, error, mutate } = useSWR(
workspaceSlug && projectId && issueId ? `SUBSCRIPTION_STATUE_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () =>
userNotificationServices.getIssueNotificationSubscriptionStatus(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
const handleUnsubscribe = useCallback(() => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate(
{
subscribed: false,
},
false
);
userNotificationServices
.unsubscribeFromIssueNotifications(workspaceSlug.toString(), projectId.toString(), issueId.toString())
.then(() => {
mutate({
subscribed: false,
});
});
}, [workspaceSlug, projectId, issueId, mutate]);
const handleSubscribe = useCallback(() => {
if (!workspaceSlug || !projectId || !issueId || !user) return;
mutate(
{
subscribed: true,
},
false
);
userNotificationServices
.subscribeToIssueNotifications(workspaceSlug.toString(), projectId.toString(), issueId.toString())
.then(() => {
mutate({
subscribed: true,
});
});
}, [workspaceSlug, projectId, issueId, mutate, user]);
return {
loading: !data && !error,
subscribed: data?.subscribed,
handleSubscribe,
handleUnsubscribe,
} as const;
};
export default useUserIssueNotificationSubscription;

View file

@ -1,317 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
// swr
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import type { NotificationType, NotificationCount, IMarkAllAsReadPayload } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// constant
import { UNREAD_NOTIFICATIONS_COUNT, getPaginatedNotificationKey } from "@/constants/fetch-keys";
// services
import { NotificationService } from "@/services/notification.service";
const PER_PAGE = 30;
const userNotificationServices = new NotificationService();
const useUserNotification = (): any => {
const { workspaceSlug } = useParams();
const [snoozed, setSnoozed] = useState<boolean>(false);
const [archived, setArchived] = useState<boolean>(false);
const [readNotification, setReadNotification] = useState<boolean>(false);
const [fetchNotifications, setFetchNotifications] = useState<boolean>(false);
const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<NotificationType>("assigned");
const params = useMemo(
() => ({
type: snoozed || archived || readNotification ? undefined : selectedTab,
snoozed,
archived,
read: !readNotification ? null : false,
per_page: PER_PAGE,
}),
[archived, readNotification, selectedTab, snoozed]
);
const {
data: paginatedData,
size,
setSize,
isLoading,
isValidating,
mutate: notificationMutate,
} = useSWRInfinite(
fetchNotifications && workspaceSlug
? (index, prevData) => getPaginatedNotificationKey(index, prevData, workspaceSlug.toString(), params)
: () => null,
async (url: string) => await userNotificationServices.getNotifications(url)
);
const isLoadingMore = isLoading || (size > 0 && paginatedData && typeof paginatedData[size - 1] === "undefined");
const isEmpty = paginatedData?.[0]?.results?.length === 0;
const notifications = paginatedData ? paginatedData.map((d) => d.results).flat() : undefined;
const hasMore = isEmpty || (paginatedData && paginatedData[paginatedData.length - 1].next_page_results);
const isRefreshing = isValidating && paginatedData && paginatedData.length === size;
const { data: notificationCount, mutate: mutateNotificationCount } = useSWR(
workspaceSlug ? UNREAD_NOTIFICATIONS_COUNT(workspaceSlug.toString()) : null,
() => (workspaceSlug ? userNotificationServices.getUnreadNotificationsCount(workspaceSlug.toString()) : null)
);
const handleReadMutation = (action: "read" | "unread") => {
const notificationCountNumber = action === "read" ? -1 : 1;
mutateNotificationCount((prev: any) => {
if (!prev) return prev;
const notificationType: keyof NotificationCount =
selectedTab === "assigned" ? "my_issues" : selectedTab === "created" ? "created_issues" : "watching_issues";
return {
...prev,
[notificationType]: prev[notificationType] + notificationCountNumber,
};
}, false);
};
const mutateNotification = (notificationId: string, value: object) => {
notificationMutate((previousNotifications: any) => {
if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor(
previousNotifications
.map((d: any) => d.results)
.flat()
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
);
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification: any) => notification.id === notificationId
);
if (notificationIndexInPage === -1) return previousNotifications;
notificationIndexInPage = notificationIndexInPage === -1 ? 0 : notificationIndexInPage % PER_PAGE;
if (notificationIndex === -1) return previousNotifications;
if (notificationIndexInPage === -1) return previousNotifications;
const key = Object.keys(value)[0];
(previousNotifications[notificationIndex].results[notificationIndexInPage] as any)[key] = (value as any)[key];
return previousNotifications;
}, false);
};
const removeNotification = (notificationId: string) => {
notificationMutate((previousNotifications: any) => {
if (!previousNotifications) return previousNotifications;
const notificationIndex = Math.floor(
previousNotifications
.map((d: any) => d.results)
.flat()
.findIndex((notification: any) => notification.id === notificationId) / PER_PAGE
);
let notificationIndexInPage = previousNotifications[notificationIndex].results.findIndex(
(notification: any) => notification.id === notificationId
);
if (notificationIndexInPage === -1) return previousNotifications;
notificationIndexInPage = notificationIndexInPage === -1 ? 0 : notificationIndexInPage % PER_PAGE;
if (notificationIndex === -1) return previousNotifications;
if (notificationIndexInPage === -1) return previousNotifications;
previousNotifications[notificationIndex].results.splice(notificationIndexInPage, 1);
return previousNotifications;
}, false);
};
const markNotificationReadStatus = async (notificationId: string) => {
if (!workspaceSlug) return;
const isRead = notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
handleReadMutation(isRead ? "unread" : "read");
mutateNotification(notificationId, { read_at: isRead ? null : new Date() });
if (readNotification) removeNotification(notificationId);
if (isRead) {
await userNotificationServices
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
mutateNotificationCount();
});
} else {
await userNotificationServices
.markUserNotificationAsRead(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
mutateNotificationCount();
});
}
};
const markNotificationAsRead = async (notificationId: string) => {
if (!workspaceSlug) return;
const isRead = notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
if (isRead) return;
mutateNotification(notificationId, { read_at: new Date() });
handleReadMutation("read");
await userNotificationServices.markUserNotificationAsRead(workspaceSlug.toString(), notificationId).catch(() => {
throw new Error("Something went wrong");
});
mutateNotificationCount();
};
const markNotificationArchivedStatus = async (notificationId: string) => {
if (!workspaceSlug) return;
const isArchived = notifications?.find((notification) => notification.id === notificationId)?.archived_at !== null;
if (!isArchived) {
handleReadMutation("read");
removeNotification(notificationId);
} else {
if (archived) {
removeNotification(notificationId);
}
}
if (isArchived) {
await userNotificationServices
.markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationMutate();
mutateNotificationCount();
});
} else {
await userNotificationServices
.markUserNotificationAsArchived(workspaceSlug.toString(), notificationId)
.catch(() => {
throw new Error("Something went wrong");
})
.finally(() => {
notificationMutate();
mutateNotificationCount();
});
}
};
const markSnoozeNotification = async (notificationId: string, dateTime?: Date) => {
if (!workspaceSlug) return;
const isSnoozed = notifications?.find((notification) => notification.id === notificationId)?.snoozed_till !== null;
mutateNotification(notificationId, { snoozed_till: isSnoozed ? null : dateTime });
if (isSnoozed) {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: null,
})
.finally(() => {
notificationMutate();
});
} else {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: dateTime,
})
.catch(() => {
new Error("Something went wrong");
})
.finally(() => {
notificationMutate();
});
}
};
const markAllNotificationsAsRead = async () => {
if (!workspaceSlug) return;
let markAsReadParams: IMarkAllAsReadPayload;
if (snoozed) markAsReadParams = { archived: false, snoozed: true };
else if (archived) markAsReadParams = { archived: true, snoozed: false };
else markAsReadParams = { archived: false, snoozed: false, type: readNotification ? "all" : selectedTab };
await userNotificationServices
.markAllNotificationsAsRead(workspaceSlug.toString(), markAsReadParams)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "All Notifications marked as read.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Something went wrong. Please try again.",
});
})
.finally(() => {
notificationMutate();
mutateNotificationCount();
});
};
return {
notifications,
notificationMutate,
markNotificationReadStatus,
markNotificationArchivedStatus,
markSnoozeNotification,
snoozed,
setSnoozed,
archived,
setArchived,
readNotification,
setReadNotification,
selectedNotificationForSnooze,
setSelectedNotificationForSnooze,
selectedTab,
setSelectedTab,
totalNotificationCount: notificationCount
? notificationCount.created_issues + notificationCount.watching_issues + notificationCount.my_issues
: null,
notificationCount,
mutateNotificationCount,
setSize,
isLoading,
isLoadingMore,
hasMore,
isRefreshing,
setFetchNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
};
};
export default useUserNotification;

View file

@ -1,5 +1,13 @@
// types // types
import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity, TIssuesResponse, TBulkOperationsPayload } from "@plane/types"; import type {
TIssue,
IIssueDisplayProperties,
TIssueLink,
TIssueSubIssues,
TIssueActivity,
TIssuesResponse,
TBulkOperationsPayload,
} from "@plane/types";
// helpers // helpers
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
// services // services
@ -272,4 +280,35 @@ export class IssueService extends APIService {
throw error?.response?.data; throw error?.response?.data;
}); });
} }
// issue subscriptions
async getIssueNotificationSubscriptionStatus(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{
subscribed: boolean;
}> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async unsubscribeFromIssueNotifications(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async subscribeToIssueNotifications(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
} }

View file

@ -1,144 +0,0 @@
// services
import type {
IUserNotification,
INotificationParams,
NotificationCount,
PaginatedUserNotification,
IMarkAllAsReadPayload,
} from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
import { APIService } from "@/services/api.service";
// types
// helpers
export class NotificationService extends APIService {
constructor() {
super(API_BASE_URL);
}
async getUserNotifications(workspaceSlug: string, params: INotificationParams): Promise<IUserNotification[]> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserNotificationDetailById(workspaceSlug: string, notificationId: string): Promise<IUserNotification> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsRead(workspaceSlug: string, notificationId: string): Promise<IUserNotification> {
return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsUnread(workspaceSlug: string, notificationId: string): Promise<IUserNotification> {
return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsArchived(workspaceSlug: string, notificationId: string): Promise<IUserNotification> {
return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsUnarchived(workspaceSlug: string, notificationId: string): Promise<IUserNotification> {
return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchUserNotification(
workspaceSlug: string,
notificationId: string,
data: Partial<IUserNotification>
): Promise<IUserNotification> {
return this.patch(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteUserNotification(workspaceSlug: string, notificationId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async subscribeToIssueNotifications(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueNotificationSubscriptionStatus(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<{
subscribed: boolean;
}> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async unsubscribeFromIssueNotifications(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUnreadNotificationsCount(workspaceSlug: string): Promise<NotificationCount> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getNotifications(url: string): Promise<PaginatedUserNotification> {
return this.get(url)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markAllNotificationsAsRead(workspaceSlug: string, payload: IMarkAllAsReadPayload): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/mark-all-read/`, {
...payload,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -0,0 +1,118 @@
/* eslint-disable no-useless-catch */
import type {
TNotificationPaginatedInfo,
TNotificationPaginatedInfoQueryParams,
TNotification,
TUnreadNotificationsCount,
} from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class WorkspaceNotificationService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchUnreadNotificationsCount(workspaceSlug: string): Promise<TUnreadNotificationsCount | undefined> {
try {
const { data } = await this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`);
return data || undefined;
} catch (error) {
throw error;
}
}
async fetchNotifications(
workspaceSlug: string,
params: TNotificationPaginatedInfoQueryParams
): Promise<TNotificationPaginatedInfo | undefined> {
try {
const { data } = await this.get(`/api/workspaces/${workspaceSlug}/users/notifications`, {
params,
});
return data || undefined;
} catch (error) {
throw error;
}
}
async updateNotificationById(
workspaceSlug: string,
notificationId: string,
payload: Partial<TNotification>
): Promise<TNotification | undefined> {
try {
const { data } = await this.patch(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`,
payload
);
return data || undefined;
} catch (error) {
throw error;
}
}
async markNotificationAsRead(workspaceSlug: string, notificationId: string): Promise<TNotification | undefined> {
try {
const { data } = await this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`);
return data || undefined;
} catch (error) {
throw error;
}
}
async markNotificationAsUnread(workspaceSlug: string, notificationId: string): Promise<TNotification | undefined> {
try {
const { data } = await this.delete(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`
);
return data || undefined;
} catch (error) {
throw error;
}
}
async markNotificationAsArchived(workspaceSlug: string, notificationId: string): Promise<TNotification | undefined> {
try {
const { data } = await this.post(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`
);
return data || undefined;
} catch (error) {
throw error;
}
}
async markNotificationAsUnArchived(
workspaceSlug: string,
notificationId: string
): Promise<TNotification | undefined> {
try {
const { data } = await this.delete(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`
);
return data || undefined;
} catch (error) {
throw error;
}
}
async markAllNotificationsAsRead(
workspaceSlug: string,
payload: TNotificationPaginatedInfoQueryParams
): Promise<TNotification | undefined> {
try {
const { data } = await this.post(`/api/workspaces/${workspaceSlug}/users/notifications/mark-all-read/`, payload);
return data || undefined;
} catch (error) {
throw error;
}
}
}
const workspaceNotificationService = new WorkspaceNotificationService();
export default workspaceNotificationService;

View file

@ -1,7 +1,7 @@
import set from "lodash/set"; import set from "lodash/set";
import { action, makeObservable, observable, runInAction } from "mobx"; import { action, makeObservable, observable, runInAction } from "mobx";
// services // services
import { NotificationService } from "@/services/notification.service"; import { IssueService } from "@/services/issue/issue.service";
// types // types
import { IIssueDetail } from "./root.store"; import { IIssueDetail } from "./root.store";
@ -25,7 +25,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore {
// root store // root store
rootIssueDetail: IIssueDetail; rootIssueDetail: IIssueDetail;
// services // services
notificationService; issueService;
constructor(rootStore: IIssueDetail) { constructor(rootStore: IIssueDetail) {
makeObservable(this, { makeObservable(this, {
@ -40,7 +40,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore {
// root store // root store
this.rootIssueDetail = rootStore; this.rootIssueDetail = rootStore;
// services // services
this.notificationService = new NotificationService(); this.issueService = new IssueService();
} }
// helper methods // helper methods
@ -62,7 +62,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore {
fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => { fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => {
try { try {
const subscription = await this.notificationService.getIssueNotificationSubscriptionStatus( const subscription = await this.issueService.getIssueNotificationSubscriptionStatus(
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId issueId
@ -85,7 +85,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore {
set(this.subscriptionMap, [issueId, currentUserId], true); set(this.subscriptionMap, [issueId, currentUserId], true);
}); });
await this.notificationService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId); await this.issueService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId);
} catch (error) { } catch (error) {
this.fetchSubscriptions(workspaceSlug, projectId, issueId); this.fetchSubscriptions(workspaceSlug, projectId, issueId);
throw error; throw error;
@ -101,7 +101,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore {
set(this.subscriptionMap, [issueId, currentUserId], false); set(this.subscriptionMap, [issueId, currentUserId], false);
}); });
await this.notificationService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId); await this.issueService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId);
} catch (error) { } catch (error) {
this.fetchSubscriptions(workspaceSlug, projectId, issueId); this.fetchSubscriptions(workspaceSlug, projectId, issueId);
throw error; throw error;

View file

@ -0,0 +1,321 @@
/* eslint-disable no-useless-catch */
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { IUserLite, TNotification, TNotificationData } from "@plane/types";
// services
import workspaceNotificationService from "@/services/workspace-notification.service";
// store
import { CoreRootStore } from "../root.store";
export interface INotification extends TNotification {
// observables
// computed
asJson: TNotification;
// computed functions
// helper functions
mutateNotification: (notification: Partial<TNotification>) => void;
// actions
updateNotification: (workspaceSlug: string, payload: Partial<TNotification>) => Promise<TNotification | undefined>;
markNotificationAsRead: (workspaceSlug: string) => Promise<TNotification | undefined>;
markNotificationAsUnRead: (workspaceSlug: string) => Promise<TNotification | undefined>;
archiveNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
unArchiveNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
snoozeNotification: (workspaceSlug: string, snoozeTill: Date) => Promise<TNotification | undefined>;
unSnoozeNotification: (workspaceSlug: string) => Promise<TNotification | undefined>;
}
export class Notification implements INotification {
// observables
id: string | undefined = undefined;
title: string | undefined = undefined;
data: TNotificationData | undefined = undefined;
entity_identifier: string | undefined = undefined;
entity_name: string | undefined = undefined;
message_html: string | undefined = undefined;
message: undefined = undefined;
message_stripped: undefined = undefined;
sender: string | undefined = undefined;
receiver: string | undefined = undefined;
triggered_by: string | undefined = undefined;
triggered_by_details: IUserLite | undefined = undefined;
read_at: string | undefined = undefined;
archived_at: string | undefined = undefined;
snoozed_till: string | undefined = undefined;
workspace: string | undefined = undefined;
project: string | undefined = undefined;
created_at: string | undefined = undefined;
updated_at: string | undefined = undefined;
created_by: string | undefined = undefined;
updated_by: string | undefined = undefined;
constructor(
private store: CoreRootStore,
private notification: TNotification
) {
makeObservable(this, {
// observables
id: observable.ref,
title: observable.ref,
data: observable,
entity_identifier: observable.ref,
entity_name: observable.ref,
message_html: observable.ref,
message: observable.ref,
message_stripped: observable.ref,
sender: observable.ref,
receiver: observable.ref,
triggered_by: observable.ref,
triggered_by_details: observable,
read_at: observable.ref,
archived_at: observable.ref,
snoozed_till: observable.ref,
workspace: observable.ref,
project: observable.ref,
created_at: observable.ref,
updated_at: observable.ref,
created_by: observable.ref,
updated_by: observable.ref,
// computed
asJson: computed,
// actions
updateNotification: action,
markNotificationAsRead: action,
markNotificationAsUnRead: action,
archiveNotification: action,
unArchiveNotification: action,
snoozeNotification: action,
unSnoozeNotification: action,
});
this.id = this.notification.id;
this.title = this.notification.title;
this.data = this.notification.data;
this.entity_identifier = this.notification.entity_identifier;
this.entity_name = this.notification.entity_name;
this.message_html = this.notification.message_html;
this.message = this.notification.message;
this.message_stripped = this.notification.message_stripped;
this.sender = this.notification.sender;
this.receiver = this.notification.receiver;
this.triggered_by = this.notification.triggered_by;
this.triggered_by_details = this.notification.triggered_by_details;
this.read_at = this.notification.read_at;
this.archived_at = this.notification.archived_at;
this.snoozed_till = this.notification.snoozed_till;
this.workspace = this.notification.workspace;
this.project = this.notification.project;
this.created_at = this.notification.created_at;
this.updated_at = this.notification.updated_at;
this.created_by = this.notification.created_by;
this.updated_by = this.notification.updated_by;
}
// computed
/**
* @description get notification as json
*/
get asJson() {
return {
id: this.id,
title: this.title,
data: this.data,
entity_identifier: this.entity_identifier,
entity_name: this.entity_name,
message_html: this.message_html,
message: this.message,
message_stripped: this.message_stripped,
sender: this.sender,
receiver: this.receiver,
triggered_by: this.triggered_by,
triggered_by_details: this.triggered_by_details,
read_at: this.read_at,
archived_at: this.archived_at,
snoozed_till: this.snoozed_till,
workspace: this.workspace,
project: this.project,
created_at: this.created_at,
updated_at: this.updated_at,
created_by: this.created_by,
updated_by: this.updated_by,
};
}
// computed functions
// helper functions
mutateNotification = (notification: Partial<TNotification>) => {
Object.entries(notification).forEach(([key, value]) => {
if (key in this) {
set(this, key, value);
}
});
};
// actions
/**
* @description update notification
* @param { string } workspaceSlug
* @param { Partial<TNotification> } payload
* @returns { TNotification | undefined }
*/
updateNotification = async (
workspaceSlug: string,
payload: Partial<TNotification>
): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
try {
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
if (notification) {
runInAction(() => this.mutateNotification(notification));
}
return notification;
} catch (error) {
throw error;
}
};
/**
* @description mark notification as read
* @param { string } workspaceSlug
* @returns { TNotification | undefined }
*/
markNotificationAsRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationReadAt = this.read_at;
try {
const payload: Partial<TNotification> = {
read_at: new Date().toISOString(),
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.markNotificationAsRead(workspaceSlug, this.id);
if (notification) {
runInAction(() => this.mutateNotification(notification));
}
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ read_at: currentNotificationReadAt }));
throw error;
}
};
/**
* @description mark notification as unread
* @param { string } workspaceSlug
* @returns { TNotification | undefined }
*/
markNotificationAsUnRead = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationReadAt = this.read_at;
try {
const payload: Partial<TNotification> = {
read_at: undefined,
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.markNotificationAsUnread(workspaceSlug, this.id);
if (notification) {
runInAction(() => this.mutateNotification(notification));
}
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ read_at: currentNotificationReadAt }));
throw error;
}
};
/**
* @description archive notification
* @param { string } workspaceSlug
* @returns { TNotification | undefined }
*/
archiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationArchivedAt = this.archived_at;
try {
const payload: Partial<TNotification> = {
archived_at: new Date().toISOString(),
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.markNotificationAsArchived(workspaceSlug, this.id);
if (notification) {
runInAction(() => this.mutateNotification(notification));
}
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ archived_at: currentNotificationArchivedAt }));
throw error;
}
};
/**
* @description unarchive notification
* @param { string } workspaceSlug
* @returns { TNotification | undefined }
*/
unArchiveNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationArchivedAt = this.archived_at;
try {
const payload: Partial<TNotification> = {
archived_at: undefined,
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.markNotificationAsUnArchived(workspaceSlug, this.id);
if (notification) {
runInAction(() => this.mutateNotification(notification));
}
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ archived_at: currentNotificationArchivedAt }));
throw error;
}
};
/**
* @description snooze notification
* @param { string } workspaceSlug
* @param { Date } snoozeTill
* @returns { TNotification | undefined }
*/
snoozeNotification = async (workspaceSlug: string, snoozeTill: Date): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationSnoozeTill = this.snoozed_till;
try {
const payload: Partial<TNotification> = {
snoozed_till: snoozeTill.toISOString(),
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ snoozed_till: currentNotificationSnoozeTill }));
throw error;
}
};
/**
* @description un snooze notification
* @param { string } workspaceSlug
* @returns { TNotification | undefined }
*/
unSnoozeNotification = async (workspaceSlug: string): Promise<TNotification | undefined> => {
if (!this.id) return undefined;
const currentNotificationSnoozeTill = this.snoozed_till;
try {
const payload: Partial<TNotification> = {
snoozed_till: undefined,
};
runInAction(() => this.mutateNotification(payload));
const notification = await workspaceNotificationService.updateNotificationById(workspaceSlug, this.id, payload);
return notification;
} catch (error) {
runInAction(() => this.mutateNotification({ snoozed_till: currentNotificationSnoozeTill }));
throw error;
}
};
}

View file

@ -0,0 +1,319 @@
import isEmpty from "lodash/isEmpty";
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import {
TNotification,
TNotificationFilter,
TNotificationPaginatedInfo,
TNotificationPaginatedInfoQueryParams,
TUnreadNotificationsCount,
} from "@plane/types";
// constants
import {
ENotificationLoader,
ENotificationQueryParamType,
ENotificationTab,
TNotificationTab,
} from "@/constants/notification";
// services
import workspaceNotificationService from "@/services/workspace-notification.service";
// store
import { Notification, INotification } from "@/store/notifications/notification";
import { CoreRootStore } from "@/store/root.store";
type TNotificationLoader = ENotificationLoader | undefined;
type TNotificationQueryParamType = ENotificationQueryParamType;
export interface IWorkspaceNotificationStore {
// observables
loader: TNotificationLoader;
unreadNotificationsCount: TUnreadNotificationsCount | undefined;
notifications: Record<string, INotification>; // notification_id -> notification
currentNotificationTab: TNotificationTab;
paginationInfo: Omit<TNotificationPaginatedInfo, "results"> | undefined;
filters: TNotificationFilter;
// computed
totalUnreadNotificationsCount: number;
// computed functions
notificationIdsByWorkspaceId: (workspaceId: string) => string[] | undefined;
// helper actions
mutateNotifications: (notifications: TNotification[]) => void;
updateFilters: <T extends keyof TNotificationFilter>(key: T, value: TNotificationFilter[T]) => void;
updateBulkFilters: (filters: Partial<TNotificationFilter>) => void;
// actions
setCurrentNotificationTab: (tab: TNotificationTab) => void;
getUnreadNotificationsCount: (workspaceSlug: string) => Promise<TUnreadNotificationsCount | undefined>;
getNotifications: (
workspaceSlug: string,
loader?: TNotificationLoader,
queryCursorType?: TNotificationQueryParamType
) => Promise<TNotificationPaginatedInfo | undefined>;
markAllNotificationsAsRead: (workspaceId: string) => Promise<void>;
}
export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
// constants
paginatedCount = 30;
// observables
loader: TNotificationLoader = undefined;
unreadNotificationsCount: TUnreadNotificationsCount | undefined = undefined;
notifications: Record<string, INotification> = {};
currentNotificationTab: TNotificationTab = ENotificationTab.ALL;
paginationInfo: Omit<TNotificationPaginatedInfo, "results"> | undefined = undefined;
filters: TNotificationFilter = {
type: {
assigned: false,
created: false,
subscribed: false,
},
snoozed: false,
archived: false,
read: false,
};
constructor(private store: CoreRootStore) {
makeObservable(this, {
// observables
loader: observable.ref,
unreadNotificationsCount: observable.ref,
notifications: observable,
currentNotificationTab: observable.ref,
paginationInfo: observable,
filters: observable,
// computed
totalUnreadNotificationsCount: computed,
// helper actions
setCurrentNotificationTab: action,
mutateNotifications: action,
updateFilters: action,
updateBulkFilters: action,
// actions
getUnreadNotificationsCount: action,
getNotifications: action,
markAllNotificationsAsRead: action,
});
}
// computed
get totalUnreadNotificationsCount() {
let count: number = 0;
if (!this.unreadNotificationsCount) return count;
Object.values(this.unreadNotificationsCount).forEach((value) => {
count += value || 0;
});
return count;
}
// computed functions
/**
* @description get notification ids by workspace id
* @param { string } workspaceId
*/
notificationIdsByWorkspaceId = computedFn((workspaceId: string) => {
if (!workspaceId || isEmpty(this.notifications)) return undefined;
const workspaceNotificationIds = Object.values(this.notifications || {})
.filter((n) => n.workspace === workspaceId)
.filter((n) => {
if (!this.filters.archived && !this.filters.snoozed) {
if (n.archived_at) {
return false;
} else if (n.snoozed_till) {
return false;
} else {
return true;
}
} else {
if (this.filters.snoozed) {
return n.snoozed_till ? true : false;
} else if (this.filters.archived) {
return n.archived_at ? true : false;
} else {
return true;
}
}
})
// .filter((n) => (this.filters.read ? (n.read_at ? true : false) : n.read_at ? false : true))
.map((n) => n.id) as string[];
return workspaceNotificationIds;
});
// helper functions
/**
* @description generate notification query params
* @returns { object }
*/
generateNotificationQueryParams = (paramType: TNotificationQueryParamType): TNotificationPaginatedInfoQueryParams => {
const queryParamsType =
Object.entries(this.filters.type)
.filter(([, value]) => value)
.map(([key]) => key)
.join(",") || undefined;
const currentPage = this.paginationInfo ? Number(this.paginationInfo?.prev_cursor?.split(":")[1] || 0) + 1 : 0;
const queryCursorNext =
paramType === ENotificationQueryParamType.INIT
? `${this.paginatedCount}:0:0`
: paramType === ENotificationQueryParamType.CURRENT
? `${this.paginatedCount}:${currentPage}:0`
: paramType === ENotificationQueryParamType.NEXT && this.paginationInfo
? this.paginationInfo?.next_cursor
: `${this.paginatedCount}:${currentPage}:0`;
const queryParams: TNotificationPaginatedInfoQueryParams = {
type: queryParamsType,
snoozed: this.filters.snoozed || false,
archived: this.filters.archived || false,
read: undefined,
per_page: this.paginatedCount,
cursor: queryCursorNext,
};
// NOTE: This validation is required to show all the read and unread notifications in a single place it may change in future.
queryParams.read = this.filters.read === true ? false : undefined;
return queryParams;
};
// helper actions
/**
* @description mutate and validate current existing and new notifications
* @param { TNotification[] } notifications
*/
mutateNotifications = (notifications: TNotification[]) => {
(notifications || []).forEach((notification) => {
if (!notification.id) return;
if (this.notifications[notification.id]) {
this.notifications[notification.id].mutateNotification(notification);
} else {
set(this.notifications, notification.id, new Notification(this.store, notification));
}
});
};
/**
* @description update filters
* @param { T extends keyof TNotificationFilter } key
* @param { TNotificationFilter[T] } value
*/
updateFilters = <T extends keyof TNotificationFilter>(key: T, value: TNotificationFilter[T]) => {
set(this.filters, key, value);
const { workspaceSlug } = this.store.router;
if (!workspaceSlug) return;
set(this, "notifications", {});
this.getNotifications(workspaceSlug, ENotificationLoader.INIT_LOADER, ENotificationQueryParamType.INIT);
};
/**
* @description update bulk filters
* @param { Partial<TNotificationFilter> } filters
*/
updateBulkFilters = (filters: Partial<TNotificationFilter>) => {
Object.entries(filters).forEach(([key, value]) => {
set(this.filters, key, value);
});
const { workspaceSlug } = this.store.router;
if (!workspaceSlug) return;
set(this, "notifications", {});
this.getNotifications(workspaceSlug, ENotificationLoader.INIT_LOADER, ENotificationQueryParamType.INIT);
};
// actions
/**
* @description set notification tab
* @returns { void }
*/
setCurrentNotificationTab = (tab: TNotificationTab): void => {
set(this, "currentNotificationTab", tab);
};
/**
* @description get unread notifications count
* @param { string } workspaceSlug,
* @param { TNotificationQueryParamType } queryCursorType,
* @returns { number | undefined }
*/
getUnreadNotificationsCount = async (workspaceSlug: string): Promise<TUnreadNotificationsCount | undefined> => {
try {
const unreadNotificationCount = await workspaceNotificationService.fetchUnreadNotificationsCount(workspaceSlug);
if (unreadNotificationCount)
runInAction(() => {
set(this, "unreadNotificationsCount", unreadNotificationCount);
});
return unreadNotificationCount || undefined;
} catch (error) {
console.error("WorkspaceNotificationStore -> getUnreadNotificationsCount -> error", error);
throw error;
}
};
/**
* @description get all workspace notification
* @param { string } workspaceSlug,
* @param { TNotificationLoader } loader,
* @returns { TNotification | undefined }
*/
getNotifications = async (
workspaceSlug: string,
loader: TNotificationLoader = ENotificationLoader.INIT_LOADER,
queryParamType: TNotificationQueryParamType = ENotificationQueryParamType.INIT
): Promise<TNotificationPaginatedInfo | undefined> => {
this.loader = loader;
try {
const queryParams = this.generateNotificationQueryParams(queryParamType);
await this.getUnreadNotificationsCount(workspaceSlug);
const notificationResponse = await workspaceNotificationService.fetchNotifications(workspaceSlug, queryParams);
if (notificationResponse) {
const { results, ...paginationInfo } = notificationResponse;
runInAction(() => {
if (results) {
this.mutateNotifications(results);
}
set(this, "paginationInfo", paginationInfo);
});
}
return notificationResponse;
} catch (error) {
console.error("WorkspaceNotificationStore -> getNotifications -> error", error);
throw error;
} finally {
runInAction(() => (this.loader = undefined));
}
};
/**
* @description mark all notifications as read
* @param { string } workspaceSlug,
* @returns { void }
*/
markAllNotificationsAsRead = async (workspaceSlug: string): Promise<void> => {
try {
this.loader = ENotificationLoader.MARK_ALL_AS_READY;
const queryParams = this.generateNotificationQueryParams(ENotificationQueryParamType.INIT);
const params = {
type: queryParams.type,
snoozed: queryParams.snoozed,
archived: queryParams.archived,
read: queryParams.read,
};
await workspaceNotificationService.markAllNotificationsAsRead(workspaceSlug, params);
runInAction(() => {
Object.values(this.notifications).forEach((notification) =>
notification.mutateNotification({
read_at: new Date().toUTCString(),
})
);
});
} catch (error) {
console.error("WorkspaceNotificationStore -> markAllNotificationsAsRead -> error", error);
throw error;
} finally {
runInAction(() => (this.loader = undefined));
}
};
}

View file

@ -15,6 +15,7 @@ import { IMemberRootStore, MemberRootStore } from "./member";
import { IModuleStore, ModulesStore } from "./module.store"; import { IModuleStore, ModulesStore } from "./module.store";
import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store";
import { IMultipleSelectStore, MultipleSelectStore } from "./multiple_select.store"; import { IMultipleSelectStore, MultipleSelectStore } from "./multiple_select.store";
import { IWorkspaceNotificationStore, WorkspaceNotificationStore } from "./notifications/workspace-notifications.store";
import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"; import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store";
import { IProjectRootStore, ProjectRootStore } from "./project"; import { IProjectRootStore, ProjectRootStore } from "./project";
import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
@ -50,6 +51,7 @@ export class CoreRootStore {
projectInbox: IProjectInboxStore; projectInbox: IProjectInboxStore;
projectEstimate: IProjectEstimateStore; projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore; multipleSelect: IMultipleSelectStore;
workspaceNotification: IWorkspaceNotificationStore;
constructor() { constructor() {
this.router = new RouterStore(); this.router = new RouterStore();
@ -75,6 +77,7 @@ export class CoreRootStore {
this.projectInbox = new ProjectInboxStore(this); this.projectInbox = new ProjectInboxStore(this);
this.projectPages = new ProjectPageStore(this); this.projectPages = new ProjectPageStore(this);
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
} }
resetOnSignOut() { resetOnSignOut() {
@ -103,5 +106,6 @@ export class CoreRootStore {
this.projectPages = new ProjectPageStore(this); this.projectPages = new ProjectPageStore(this);
this.multipleSelect = new MultipleSelectStore(); this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
} }
} }