[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:
parent
8d5d0422e9
commit
209dc57307
59 changed files with 2337 additions and 1623 deletions
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
|
||||||
2
packages/types/src/index.d.ts
vendored
2
packages/types/src/index.d.ts
vendored
|
|
@ -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";
|
||||||
|
|
|
||||||
79
packages/types/src/notifications.d.ts
vendored
79
packages/types/src/notifications.d.ts
vendored
|
|
@ -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;
|
|
||||||
}
|
|
||||||
90
packages/types/src/workspace-notifications.d.ts
vendored
Normal file
90
packages/types/src/workspace-notifications.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
15
web/app/[workspaceSlug]/(projects)/notifications/layout.tsx
Normal file
15
web/app/[workspaceSlug]/(projects)/notifications/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
web/app/[workspaceSlug]/(projects)/notifications/page.tsx
Normal file
46
web/app/[workspaceSlug]/(projects)/notifications/page.tsx
Normal 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;
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from "./notification-card";
|
|
||||||
export * from "./notification-popover";
|
|
||||||
export * from "./select-snooze-till-modal";
|
|
||||||
export * from "./notification-header";
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
2
web/core/components/workspace-notifications/index.ts
Normal file
2
web/core/components/workspace-notifications/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./notification-app-sidebar-option";
|
||||||
|
export * from "./sidebar";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./applied-filter";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./options";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./menu-option";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export * from "./loader";
|
||||||
|
|
||||||
|
export * from "./root";
|
||||||
|
|
||||||
|
export * from "./header";
|
||||||
|
|
||||||
|
export * from "./filters";
|
||||||
|
|
||||||
|
export * from "./notification-card";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./item";
|
||||||
|
export * from "./options";
|
||||||
|
|
@ -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}
|
||||||
|
{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)},
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export * from "./root";
|
||||||
|
|
||||||
|
export * from "./read";
|
||||||
|
export * from "./archive";
|
||||||
|
export * from "./snooze";
|
||||||
|
|
||||||
|
export * from "./button";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./root";
|
||||||
|
export * from "./modal";
|
||||||
|
|
@ -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()}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
70
web/core/components/workspace-notifications/sidebar/root.tsx
Normal file
70
web/core/components/workspace-notifications/sidebar/root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()}`;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
2
web/core/hooks/store/notifications/index.ts
Normal file
2
web/core/hooks/store/notifications/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./use-workspace-notifications";
|
||||||
|
export * from "./use-notification";
|
||||||
13
web/core/hooks/store/notifications/use-notification.ts
Normal file
13
web/core/hooks/store/notifications/use-notification.ts
Normal 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] ?? {};
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
118
web/core/services/workspace-notification.service.ts
Normal file
118
web/core/services/workspace-notification.service.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
321
web/core/store/notifications/notification.ts
Normal file
321
web/core/store/notifications/notification.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
319
web/core/store/notifications/workspace-notifications.store.ts
Normal file
319
web/core/store/notifications/workspace-notifications.store.ts
Normal 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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue