From 209dc573073866f61dd0151aa486200579a26377 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Fri, 28 Jun 2024 19:00:48 +0530 Subject: [PATCH] [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 --- .../plane/app/views/notification/base.py | 37 +- packages/types/src/enums.ts | 7 + packages/types/src/index.d.ts | 2 +- packages/types/src/notifications.d.ts | 79 ---- .../types/src/workspace-notifications.d.ts | 90 ++++ .../(projects)/notifications/layout.tsx | 15 + .../(projects)/notifications/page.tsx | 46 ++ .../components/common/breadcrumb-link.tsx | 12 +- web/core/components/core/app-header.tsx | 2 +- .../roots/archived-issue-layout-root.tsx | 6 +- .../issues/peek-overview/header.tsx | 4 +- .../components/issues/peek-overview/root.tsx | 4 +- .../components/issues/peek-overview/view.tsx | 32 +- web/core/components/notifications/index.ts | 4 - .../notifications/notification-card.tsx | 423 ------------------ .../notifications/notification-header.tsx | 223 --------- .../notifications/notification-popover.tsx | 226 ---------- .../workspace-notifications/index.ts | 2 + .../notification-app-sidebar-option.tsx | 36 ++ .../sidebar/filters/applied-filter.tsx | 64 +++ .../sidebar/filters/index.ts | 2 + .../sidebar/filters/root.tsx | 80 ++++ .../sidebar/header/index.ts | 2 + .../sidebar/header/options/index.ts | 2 + .../sidebar/header/options/menu-option.tsx | 160 +++++++ .../sidebar/header/options/root.tsx | 51 +++ .../sidebar/header/root.tsx | 51 +++ .../workspace-notifications/sidebar/index.ts | 9 + .../sidebar/loader.tsx | 16 + .../sidebar/notification-card/index.ts | 3 + .../sidebar/notification-card/item.tsx | 169 +++++++ .../notification-card/options/archive.tsx | 58 +++ .../notification-card/options/button.tsx | 40 ++ .../notification-card/options/index.ts | 7 + .../notification-card/options/read.tsx | 54 +++ .../notification-card/options/root.tsx | 57 +++ .../notification-card/options/snooze/index.ts | 2 + .../options/snooze/modal.tsx} | 67 ++- .../notification-card/options/snooze/root.tsx | 148 ++++++ .../sidebar/notification-card/root.tsx | 56 +++ .../workspace-notifications/sidebar/root.tsx | 70 +++ .../workspace/sidebar/user-menu.tsx | 11 +- web/core/constants/dashboard.ts | 10 +- web/core/constants/empty-state.ts | 16 +- web/core/constants/fetch-keys.ts | 38 +- web/core/constants/notification.ts | 88 +++- web/core/hooks/store/index.ts | 1 + web/core/hooks/store/notifications/index.ts | 2 + .../store/notifications/use-notification.ts | 13 + .../use-workspace-notifications.ts | 12 + .../use-issue-notification-subscription.tsx | 75 ---- web/core/hooks/use-user-notifications.tsx | 317 ------------- web/core/services/issue/issue.service.ts | 41 +- web/core/services/notification.service.ts | 144 ------ .../workspace-notification.service.ts | 118 +++++ .../issue/issue-details/subscription.store.ts | 12 +- web/core/store/notifications/notification.ts | 321 +++++++++++++ .../workspace-notifications.store.ts | 319 +++++++++++++ web/core/store/root.store.ts | 4 + 59 files changed, 2337 insertions(+), 1623 deletions(-) delete mode 100644 packages/types/src/notifications.d.ts create mode 100644 packages/types/src/workspace-notifications.d.ts create mode 100644 web/app/[workspaceSlug]/(projects)/notifications/layout.tsx create mode 100644 web/app/[workspaceSlug]/(projects)/notifications/page.tsx delete mode 100644 web/core/components/notifications/index.ts delete mode 100644 web/core/components/notifications/notification-card.tsx delete mode 100644 web/core/components/notifications/notification-header.tsx delete mode 100644 web/core/components/notifications/notification-popover.tsx create mode 100644 web/core/components/workspace-notifications/index.ts create mode 100644 web/core/components/workspace-notifications/notification-app-sidebar-option.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/filters/applied-filter.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/filters/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/filters/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/header/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/header/options/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/header/options/menu-option.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/header/options/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/header/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/loader.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/item.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/index.ts create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/read.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/index.ts rename web/core/components/{notifications/select-snooze-till-modal.tsx => workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx} (90%) create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/notification-card/root.tsx create mode 100644 web/core/components/workspace-notifications/sidebar/root.tsx create mode 100644 web/core/hooks/store/notifications/index.ts create mode 100644 web/core/hooks/store/notifications/use-notification.ts create mode 100644 web/core/hooks/store/notifications/use-workspace-notifications.ts delete mode 100644 web/core/hooks/use-issue-notification-subscription.tsx delete mode 100644 web/core/hooks/use-user-notifications.tsx delete mode 100644 web/core/services/notification.service.ts create mode 100644 web/core/services/workspace-notification.service.ts create mode 100644 web/core/store/notifications/notification.ts create mode 100644 web/core/store/notifications/workspace-notifications.store.ts diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index 5af5d0a9a..2ef8ab511 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -43,8 +43,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Get query parameters snoozed = request.GET.get("snoozed", "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") + q_filters = Q() notifications = ( Notification.objects.filter( @@ -74,8 +75,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator): if read == "false": notifications = notifications.filter(read_at__isnull=True) + if read == "true": + notifications = notifications.filter(read_at__isnull=False) + + type = type.split(",") # Subscribed issues - if type == "watching": + if "subscribed" in type: issue_ids = ( IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id @@ -97,35 +102,32 @@ class NotificationViewSet(BaseViewSet, BasePaginator): .filter(created=False, assigned=False) .values_list("issue_id", flat=True) ) - notifications = notifications.filter( - entity_identifier__in=issue_ids, - ) + q_filters |= Q(entity_identifier__in=issue_ids) # Assigned Issues - if type == "assigned": + if "assigned" in type: issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter( - entity_identifier__in=issue_ids - ) + q_filters |= Q(entity_identifier__in=issue_ids) # Created issues - if type == "created": + if "created" in type: if WorkspaceMember.objects.filter( workspace__slug=slug, member=request.user, role__lt=15, is_active=True, ).exists(): - notifications = Notification.objects.none() + notifications = notifications.none() else: issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter( - entity_identifier__in=issue_ids - ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Apply the combined Q object filters + notifications = notifications.filter(q_filters) # Pagination if request.GET.get("per_page", False) and request.GET.get( @@ -200,11 +202,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator): class UnreadNotificationEndpoint(BaseAPIView): def get(self, request, slug): # Watching Issues Count - watching_issues_count = Notification.objects.filter( + subscribed_issues_count = Notification.objects.filter( workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True, archived_at__isnull=True, + snoozed_till__isnull=True, entity_identifier__in=IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id ).values_list("issue_id", flat=True), @@ -216,6 +219,7 @@ class UnreadNotificationEndpoint(BaseAPIView): receiver_id=request.user.id, read_at__isnull=True, archived_at__isnull=True, + snoozed_till__isnull=True, entity_identifier__in=IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True), @@ -227,6 +231,7 @@ class UnreadNotificationEndpoint(BaseAPIView): receiver_id=request.user.id, read_at__isnull=True, archived_at__isnull=True, + snoozed_till__isnull=True, entity_identifier__in=Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True), @@ -234,7 +239,7 @@ class UnreadNotificationEndpoint(BaseAPIView): return Response( { - "watching_issues": watching_issues_count, + "subscribed_issues": subscribed_issues_count, "my_issues": my_issues_count, "created_issues": created_issues_count, }, diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index cc8575374..08949bd17 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -37,3 +37,10 @@ export enum EEstimateUpdateStages { EDIT = "edit", SWITCH = "switch", } + +// workspace notifications +export enum ENotificationFilterType { + CREATED = "created", + ASSIGNED = "assigned", + SUBSCRIBED = "subscribed", +} diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 25c2b255b..353aeaf08 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -19,7 +19,6 @@ export * from "./auth"; export * from "./calendar"; export * from "./instance"; 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 "./view-props"; export * from "./waitlist"; @@ -28,3 +27,4 @@ export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; export * from "./publish"; +export * from "./workspace-notifications"; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts deleted file mode 100644 index d739b2309..000000000 --- a/packages/types/src/notifications.d.ts +++ /dev/null @@ -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; -} diff --git a/packages/types/src/workspace-notifications.d.ts b/packages/types/src/workspace-notifications.d.ts new file mode 100644 index 000000000..95d93b890 --- /dev/null +++ b/packages/types/src/workspace-notifications.d.ts @@ -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; +}; diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx new file mode 100644 index 000000000..49303d361 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { NotificationsSidebar } from "@/components/workspace-notifications"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 000000000..6b8a90f16 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx @@ -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 ( + <> + +
+ +
+ + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/core/components/common/breadcrumb-link.tsx b/web/core/components/common/breadcrumb-link.tsx index 2768a5d71..c04888790 100644 --- a/web/core/components/common/breadcrumb-link.tsx +++ b/web/core/components/common/breadcrumb-link.tsx @@ -1,20 +1,22 @@ "use client"; +import { ReactNode } from "react"; import Link from "next/link"; import { Tooltip } from "@plane/ui"; import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { - label?: string; + label?: string | ReactNode; href?: string; icon?: React.ReactNode | undefined; + disableTooltip?: boolean; }; export const BreadcrumbLink: React.FC = (props) => { - const { href, label, icon } = props; + const { href, label, icon, disableTooltip = false } = props; const { isMobile } = usePlatformOS(); return ( - +
  • {href ? ( @@ -25,7 +27,9 @@ export const BreadcrumbLink: React.FC = (props) => { {icon && (
    {icon}
    )} -
    {label}
    + {label && ( +
    {label}
    + )} ) : (
    diff --git a/web/core/components/core/app-header.tsx b/web/core/components/core/app-header.tsx index 83e148d4c..05f7ab4c1 100644 --- a/web/core/components/core/app-header.tsx +++ b/web/core/components/core/app-header.tsx @@ -16,7 +16,7 @@ export const AppHeader = (props: AppHeaderProps) => { <>
    -
    +
    {header}
    diff --git a/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 63ce3fe66..322c00054 100644 --- a/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -4,11 +4,7 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // mobx store // components -import { - ArchivedIssueListLayout, - ArchivedIssueAppliedFiltersRoot, - IssuePeekOverview, -} from "@/components/issues"; +import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot, IssuePeekOverview } from "@/components/issues"; import { EIssuesStoreType } from "@/constants/issue"; // ui import { useIssues } from "@/hooks/store"; diff --git a/web/core/components/issues/peek-overview/header.tsx b/web/core/components/issues/peek-overview/header.tsx index 374a280e2..9967ad067 100644 --- a/web/core/components/issues/peek-overview/header.tsx +++ b/web/core/components/issues/peek-overview/header.tsx @@ -54,6 +54,7 @@ export type PeekOverviewHeaderProps = { issueId: string; isArchived: boolean; disabled: boolean; + embedIssue: boolean; toggleDeleteIssueModal: (issueId: string | null) => void; toggleArchiveIssueModal: (issueId: string | null) => void; handleRestoreIssue: () => void; @@ -69,6 +70,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr issueId, isArchived, disabled, + embedIssue = false, removeRoutePeekId, toggleDeleteIssueModal, toggleArchiveIssueModal, @@ -123,7 +125,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr - {currentMode && ( + {currentMode && embedIssue === false && (
    = observer((props) => { - const { is_archived = false, is_draft = false } = props; + const { embedIssue = false, is_archived = false, is_draft = false } = props; // router const pathname = usePathname(); const { @@ -406,6 +407,7 @@ export const IssuePeekOverview: FC = observer((props) => { isLoading={isLoading} is_archived={is_archived} disabled={!isEditable} + embedIssue={embedIssue} issueOperations={issueOperations} /> ); diff --git a/web/core/components/issues/peek-overview/view.tsx b/web/core/components/issues/peek-overview/view.tsx index 803f02fb7..dde36db17 100644 --- a/web/core/components/issues/peek-overview/view.tsx +++ b/web/core/components/issues/peek-overview/view.tsx @@ -29,11 +29,21 @@ interface IIssueView { isLoading?: boolean; is_archived: boolean; disabled?: boolean; + embedIssue?: boolean; issueOperations: TIssueOperations; } export const IssueView: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, isLoading, is_archived, disabled = false, issueOperations } = props; + const { + workspaceSlug, + projectId, + issueId, + isLoading, + is_archived, + disabled = false, + embedIssue = false, + issueOperations, + } = props; // states const [peekMode, setPeekMode] = useState("side-peek"); const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); @@ -84,6 +94,16 @@ export const IssueView: FC = observer((props) => { removeRoutePeekId(); }; + const peekOverviewIssueClassName = cn( + !embedIssue && + "fixed z-20 flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300", + !embedIssue && { + "bottom-0 right-0 top-0 w-full md:w-[50%]": peekMode === "side-peek", + "size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal", + "inset-0 m-4": peekMode === "full-screen", + } + ); + return ( <> {issue && !is_archived && ( @@ -113,14 +133,7 @@ export const IssueView: FC = observer((props) => { {issueId && (
    = observer((props) => { projectId={projectId} isSubmitting={isSubmitting} disabled={disabled} + embedIssue={embedIssue} /> {/* content */}
    diff --git a/web/core/components/notifications/index.ts b/web/core/components/notifications/index.ts deleted file mode 100644 index 99667be22..000000000 --- a/web/core/components/notifications/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./notification-card"; -export * from "./notification-popover"; -export * from "./select-snooze-till-modal"; -export * from "./notification-header"; diff --git a/web/core/components/notifications/notification-card.tsx b/web/core/components/notifications/notification-card.tsx deleted file mode 100644 index 9b540d118..000000000 --- a/web/core/components/notifications/notification-card.tsx +++ /dev/null @@ -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; - markNotificationReadStatusToggle: (notificationId: string) => Promise; - markNotificationArchivedStatus: (notificationId: string) => Promise; - setSelectedNotificationForSnooze: (notificationId: string) => void; - markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise; -}; - -export const NotificationCard: React.FC = (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(null); - - const moreOptions = [ - { - id: 1, - name: notification.read_at ? "Mark as unread" : "Mark as read", - icon: , - 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 ? ( - - ) : ( - - ), - 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 ( - { - 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 && ( - - )} -
    - {notificationTriggeredBy.avatar && notificationTriggeredBy.avatar !== "" ? ( -
    - Profile Image -
    - ) : ( -
    - - {notificationTriggeredBy.is_bot ? ( - notificationTriggeredBy.first_name?.[0]?.toUpperCase() - ) : notificationTriggeredBy.display_name?.[0] ? ( - notificationTriggeredBy.display_name?.[0]?.toUpperCase() - ) : ( - - )} - -
    - )} -
    -
    -
    - {!notification.message ? ( -
    - - {notificationTriggeredBy.is_bot - ? notificationTriggeredBy.first_name - : notificationTriggeredBy.display_name}{" "} - - {!["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" : ""} - - {" "} - {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 - ) - ) : ( - - {sanitizeCommentForNotification(notification.data.issue_activity.new_value ?? undefined)} - - ) - ) : ( - "the issue and assigned it to you." - )} - -
    - ) : ( -
    - {notification.message} -
    - )} -
    - - {({ open }) => ( - <> - - - - {open && ( - -
    - {moreOptions.map((item) => ( - - {({ close }) => ( - - )} - - ))} - -
    { - e.stopPropagation(); - e.preventDefault(); - setShowSnoozeOptions(true); - }} - className="flex items-center gap-x-2 p-1.5" - > - - Snooze -
    -
    -
    -
    - )} - - )} -
    - {showSnoozeOptions && ( -
    - {snoozeOptions.map((item) => ( -

    { - e.stopPropagation(); - e.preventDefault(); - setShowSnoozeOptions(false); - snoozeOptionOnClick(item.value); - }} - > - {item.label} -

    - ))} -
    - )} -
    -
    - -
    -

    - {truncateText( - `${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`, - 50 - )} -

    - {notification.snoozed_till ? ( -

    - - - Till {renderFormattedDate(notification.snoozed_till)},{" "} - {renderFormattedTime(notification.snoozed_till, "12-hour")} - -

    - ) : ( -

    {calculateTimeAgo(notification.created_at)}

    - )} -
    -
    -
    - {[ - { - id: 1, - name: notification.read_at ? "Mark as unread" : "Mark as read", - icon: , - 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 ? ( - - ) : ( - - ), - 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) => ( - - - - ))} - -
    - -
    - - } - optionsClassName="!z-20" - > - {snoozeOptions.map((item) => ( - { - 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} - - ))} -
    -
    - - ); -}; diff --git a/web/core/components/notifications/notification-header.tsx b/web/core/components/notifications/notification-header.tsx deleted file mode 100644 index e72e02ef0..000000000 --- a/web/core/components/notifications/notification-header.tsx +++ /dev/null @@ -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>; - setArchived: React.Dispatch>; - setReadNotification: React.Dispatch>; - setSelectedTab: React.Dispatch>; - markAllNotificationsAsRead: () => Promise; -}; - -export const NotificationHeader: React.FC = (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 ( - <> -
    -
    - -

    Notifications

    -
    - -
    - - - - - - - - -
    - } - closeOnSelect - > - { - markAllNotificationsAsRead(); - captureEvent(NOTIFICATIONS_READ); - }} - > -
    - - Mark all as read -
    -
    - { - setArchived(false); - setReadNotification(false); - setSnoozed((prev) => !prev); - captureEvent(SNOOZED_NOTIFICATIONS); - }} - > -
    - - Show snoozed -
    -
    - { - setSnoozed(false); - setReadNotification(false); - setArchived((prev) => !prev); - captureEvent(ARCHIVED_NOTIFICATIONS); - }} - > -
    - - Show archived -
    -
    - -
    - - - -
    -
    -
    -
    - {snoozed || archived || readNotification ? ( - - ) : ( - - )} -
    - - ); -}; diff --git a/web/core/components/notifications/notification-popover.tsx b/web/core/components/notifications/notification-popover.tsx deleted file mode 100644 index ce4c6efd8..000000000 --- a/web/core/components/notifications/notification-popover.tsx +++ /dev/null @@ -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(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 ( - <> - setSelectedNotificationForSnooze(null)} - onSubmit={markSnoozeNotification} - notification={ - notifications?.find((notification: any) => notification.id === selectedNotificationForSnooze) || null - } - onSuccess={() => setSelectedNotificationForSnooze(null)} - /> - - <> - - - - - - 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 ? ( -
    -
    - {notifications.map((notification: any) => ( - setIsActive(false)} - notification={notification} - markNotificationArchivedStatus={markNotificationArchivedStatus} - markNotificationReadStatus={markNotificationAsRead} - markNotificationReadStatusToggle={markNotificationReadStatus} - setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} - markSnoozeNotification={markSnoozeNotification} - /> - ))} -
    - {isLoadingMore && ( -
    -
    - - Loading... -
    -

    Loading notifications

    -
    - )} - {hasMore && !isLoadingMore && ( - - )} -
    - ) : ( -
    - -
    - ) - ) : ( - - )} -
    -
    - -
    - - ); -}); diff --git a/web/core/components/workspace-notifications/index.ts b/web/core/components/workspace-notifications/index.ts new file mode 100644 index 000000000..2682c9114 --- /dev/null +++ b/web/core/components/workspace-notifications/index.ts @@ -0,0 +1,2 @@ +export * from "./notification-app-sidebar-option"; +export * from "./sidebar"; diff --git a/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx b/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx new file mode 100644 index 000000000..acde9b839 --- /dev/null +++ b/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx @@ -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 = 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
    ; + + return ( +
    + {getNumberCount(totalUnreadNotificationsCount)} +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/filters/applied-filter.tsx b/web/core/components/workspace-notifications/sidebar/filters/applied-filter.tsx new file mode 100644 index 000000000..babf99ba7 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/filters/applied-filter.tsx @@ -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 = 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 ( +
    + {FILTER_TYPE_OPTIONS.map((filter) => { + const isSelected = filters?.type?.[filter?.value] || false; + if (!isSelected) return <>; + return ( +
    handleFilterTypeChange(filter?.value, !isSelected)} + > +
    {filter.label}
    +
    + +
    +
    + ); + })} + +
    +
    Clear all
    +
    +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/filters/index.ts b/web/core/components/workspace-notifications/sidebar/filters/index.ts new file mode 100644 index 000000000..4f7dbf49f --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/filters/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./applied-filter"; diff --git a/web/core/components/workspace-notifications/sidebar/filters/root.tsx b/web/core/components/workspace-notifications/sidebar/filters/root.tsx new file mode 100644 index 000000000..86994c819 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/filters/root.tsx @@ -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 ( + + + (open ? "bg-custom-background-80" : "") + )} + > + + + + + + +
    + {FILTER_TYPE_OPTIONS.map((filter) => { + const isSelected = filters?.type?.[filter?.value] || false; + return ( +
    handleFilterTypeChange(filter?.value, !isSelected)} + > +
    + {isSelected && } +
    +
    + {filter.label} +
    +
    + ); + })} +
    +
    +
    +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/header/index.ts b/web/core/components/workspace-notifications/sidebar/header/index.ts new file mode 100644 index 000000000..eb738f5f3 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/header/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./options"; diff --git a/web/core/components/workspace-notifications/sidebar/header/options/index.ts b/web/core/components/workspace-notifications/sidebar/header/options/index.ts new file mode 100644 index 000000000..e4d478279 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/header/options/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./menu-option"; diff --git a/web/core/components/workspace-notifications/sidebar/header/options/menu-option.tsx b/web/core/components/workspace-notifications/sidebar/header/options/menu-option.tsx new file mode 100644 index 000000000..509dd9de3 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/header/options/menu-option.tsx @@ -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 = 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) => 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 ( + + + (open ? "bg-custom-background-80" : "") + )} + > + + + + + + +
    +
    +
    { + handleMarkAllNotificationsAsRead(); + captureEvent(NOTIFICATIONS_READ); + }} + > + +
    Mark all as read
    + {loader === ENotificationLoader.MARK_ALL_AS_READY && ( +
    + +
    + )} +
    +
    + +
    + +
    +
    handleFilterChange("read", !filters?.read)} + > + +
    + Show unread +
    + {filters?.read && ( +
    + +
    + )} +
    + +
    + handleBulkFilterChange({ + archived: !filters?.archived, + snoozed: false, + }) + } + > + +
    + Show Archived +
    + {filters?.archived && ( +
    + +
    + )} +
    + +
    + handleBulkFilterChange({ + snoozed: !filters?.snoozed, + archived: false, + }) + } + > + +
    + Show Snoozed +
    + {filters?.snoozed && ( +
    + +
    + )} +
    +
    +
    + + + + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/header/options/root.tsx b/web/core/components/workspace-notifications/sidebar/header/options/root.tsx new file mode 100644 index 000000000..2fc8c4bde --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/header/options/root.tsx @@ -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 = 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 ( +
    + {/* refetch current notifications */} + +
    + +
    +
    + + {/* notification filters */} + + + {/* notification menu options */} + +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/header/root.tsx b/web/core/components/workspace-notifications/sidebar/header/root.tsx new file mode 100644 index 000000000..d84e61a99 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/header/root.tsx @@ -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 = observer((props) => { + const { workspaceSlug, notificationsCount } = props; + + if (!workspaceSlug) return <>; + return ( +
    +
    +
    + +
    + + +
    Notifications
    +
    + {notificationsCount} +
    +
    + } + icon={} + disableTooltip + /> + } + /> + +
    + + +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/index.ts b/web/core/components/workspace-notifications/sidebar/index.ts new file mode 100644 index 000000000..52dc7bde7 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/index.ts @@ -0,0 +1,9 @@ +export * from "./loader"; + +export * from "./root"; + +export * from "./header"; + +export * from "./filters"; + +export * from "./notification-card"; diff --git a/web/core/components/workspace-notifications/sidebar/loader.tsx b/web/core/components/workspace-notifications/sidebar/loader.tsx new file mode 100644 index 000000000..7485c2c4c --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/loader.tsx @@ -0,0 +1,16 @@ +export const NotificationsLoader = () => ( +
    + {[...Array(3)].map((i) => ( +
    + +
    + +
    + + +
    +
    +
    + ))} +
    +); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/index.ts b/web/core/components/workspace-notifications/sidebar/notification-card/index.ts new file mode 100644 index 000000000..d4000aa9e --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./item"; +export * from "./options"; diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx new file mode 100644 index 000000000..506665e13 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/item.tsx @@ -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 = 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 ( +
    + {notification.read_at === null && ( +
    + )} + +
    +
    + {notificationTriggeredBy && ( + + )} +
    + +
    +
    +
    + {!notification.message ? ( + <> + + {notificationTriggeredBy?.is_bot + ? notificationTriggeredBy?.first_name + : notificationTriggeredBy?.display_name}{" "} + + {!["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" : ""} + + {" "} + {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 + ) + ) : ( + + {sanitizeCommentForNotification(notification?.data?.issue_activity.new_value ?? undefined)} + + ) + ) : ( + "the issue and assigned it to you." + )} + + + ) : ( + {notification.message} + )} +
    + +
    + +
    +
    + {notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}  + {notification?.data?.issue?.name} +
    +
    + {notification?.snoozed_till ? ( +

    + + + Till {renderFormattedDate(notification.snoozed_till)},  + {renderFormattedTime(notification.snoozed_till, "12-hour")} + +

    + ) : ( +

    + {notification.created_at && calculateTimeAgo(notification.created_at)} +

    + )} +
    +
    +
    +
    +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx new file mode 100644 index 000000000..395f0a75f --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/archive.tsx @@ -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 = 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 ( + + {data.archived_at ? ( + + ) : ( + + )} + + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx new file mode 100644 index 000000000..cfaf26cf5 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/button.tsx @@ -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 = (props) => { + const { tooltipContent = "", buttonClassName = "", children, callBack } = props; + // hooks + const { isMobile } = usePlatformOS(); + + return ( + + + + ); +}; diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/index.ts b/web/core/components/workspace-notifications/sidebar/notification-card/options/index.ts new file mode 100644 index 000000000..47dff3192 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/index.ts @@ -0,0 +1,7 @@ +export * from "./root"; + +export * from "./read"; +export * from "./archive"; +export * from "./snooze"; + +export * from "./button"; diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/read.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/read.tsx new file mode 100644 index 000000000..42b4e29f0 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/read.tsx @@ -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 = 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 ( + + + + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/root.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/root.tsx new file mode 100644 index 000000000..2017c0db8 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/root.tsx @@ -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>; + customSnoozeModal: boolean; + setCustomSnoozeModal: Dispatch>; +}; + +export const NotificationOption: FC = observer((props) => { + const { + workspaceSlug, + notificationId, + isSnoozeStateModalOpen, + setIsSnoozeStateModalOpen, + customSnoozeModal, + setCustomSnoozeModal, + } = props; + // hooks + const notification = useNotification(notificationId); + + return ( + + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/index.ts b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/index.ts new file mode 100644 index 000000000..5238728d7 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./modal"; diff --git a/web/core/components/notifications/select-snooze-till-modal.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx similarity index 90% rename from web/core/components/notifications/select-snooze-till-modal.tsx rename to web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx index 3512aa418..1ca19a5ef 100644 --- a/web/core/components/notifications/select-snooze-till-modal.tsx +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx @@ -5,40 +5,37 @@ import { useParams } from "next/navigation"; import { useForm, Controller } from "react-hook-form"; import { X } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; -import type { IUserNotification } from "@plane/types"; -import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +import { TNotification } from "@plane/types"; +import { Button, CustomSelect } from "@plane/ui"; +// components import { DateDropdown } from "@/components/dropdowns"; // constants import { allTimeIn30MinutesInterval12HoursFormat } from "@/constants/notification"; -// ui -// types // helpers -import { getDate } from "helpers/date-time.helper"; +import { getDate } from "@/helpers/date-time.helper"; -type SnoozeModalProps = { +type TNotificationSnoozeModal = { isOpen: boolean; onClose: () => void; - onSuccess: () => void; - notification: IUserNotification | null; - onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise; + onSubmit: (dateTime?: Date | undefined) => Promise; }; type FormValues = { - time: string | null; - date: Date | null; + time: string | undefined; + date: Date | undefined; period: "AM" | "PM"; }; const defaultValues: FormValues = { - time: null, - date: null, + time: undefined, + date: undefined, period: "AM", }; const timeStamps = allTimeIn30MinutesInterval12HoursFormat; -export const SnoozeNotificationModal: FC = (props) => { - const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props; +export const NotificationSnoozeModal: FC = (props) => { + const { isOpen, onClose, onSubmit: handleSubmitSnooze } = props; const { workspaceSlug } = useParams(); @@ -53,6 +50,19 @@ export const SnoozeNotificationModal: FC = (props) => { defaultValues, }); + const handleClose = () => { + // This is a workaround to fix the issue of the Notification popover modal close on closing this modal + const closeTimeout = setTimeout(() => { + onClose(); + clearTimeout(closeTimeout); + }, 50); + + const timeout = setTimeout(() => { + reset({ ...defaultValues }); + clearTimeout(timeout); + }, 500); + }; + const getTimeStamp = () => { const today = new Date(); const formDataDate = watch("date"); @@ -82,7 +92,7 @@ export const SnoozeNotificationModal: FC = (props) => { }; const onSubmit = async (formData: FormValues) => { - if (!workspaceSlug || !notification || !formData.date || !formData.time) return; + if (!workspaceSlug || !formData.date || !formData.time) return; const period = formData.period; @@ -96,30 +106,11 @@ export const SnoozeNotificationModal: FC = (props) => { dateTime?.setHours(hours); dateTime?.setMinutes(minutes); - await handleSubmitSnooze(notification.id, dateTime).then(() => { + await handleSubmitSnooze(dateTime).then(() => { handleClose(); - onSuccess(); - setToast({ - title: "Success!", - message: "Notification snoozed successfully", - type: TOAST_TYPE.SUCCESS, - }); }); }; - const handleClose = () => { - // This is a workaround to fix the issue of the Notification popover modal close on closing this modal - const closeTimeout = setTimeout(() => { - onClose(); - clearTimeout(closeTimeout); - }, 50); - - const timeout = setTimeout(() => { - reset({ ...defaultValues }); - clearTimeout(timeout); - }, 500); - }; - return ( @@ -169,10 +160,10 @@ export const SnoozeNotificationModal: FC = (props) => { rules={{ required: "Please select a date" }} render={({ field: { value, onChange } }) => ( { - setValue("time", null); + setValue("time", undefined); onChange(val); }} minDate={new Date()} diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx new file mode 100644 index 000000000..003175500 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/root.tsx @@ -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>; + customSnoozeModal: boolean; + setCustomSnoozeModal: Dispatch>; +}; + +export const NotificationItemSnoozeOption: FC = 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 ( + <> + setCustomSnoozeModal(false)} + onSubmit={handleNotificationSnoozeDate} + /> + + {({ open }) => { + if (open) setIsSnoozeStateModalOpen(true); + else setIsSnoozeStateModalOpen(false); + + return ( + <> + + + + + + + + +
    + {data.snoozed_till && ( + + )} + + {NOTIFICATION_SNOOZE_OPTIONS.map((option) => ( + + ))} +
    +
    +
    + + ); + }} +
    + + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/notification-card/root.tsx b/web/core/components/workspace-notifications/sidebar/notification-card/root.tsx new file mode 100644 index 000000000..bfff113ba --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/notification-card/root.tsx @@ -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 = 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 ( +
    + {notificationIds.map((notificationId: string) => ( + + ))} + + {/* fetch next page notifications */} + {paginationInfo && paginationInfo?.next_page_results && ( + <> + {loader === ENotificationLoader.PAGINATION_LOADER ? ( +
    +
    Loading...
    +
    + ) : ( +
    +
    + Load more +
    +
    + )} + + )} +
    + ); +}); diff --git a/web/core/components/workspace-notifications/sidebar/root.tsx b/web/core/components/workspace-notifications/sidebar/root.tsx new file mode 100644 index 000000000..3fab32300 --- /dev/null +++ b/web/core/components/workspace-notifications/sidebar/root.tsx @@ -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 ( +
    +
    + +
    + + {/* applied filters */} +
    + +
    + + {/* rendering notifications */} + {loader === "init-loader" ? ( +
    + +
    + ) : ( + <> + {notificationIds && notificationIds.length > 0 ? ( +
    + +
    + ) : ( +
    + +
    + )} + + )} +
    + ); +}); diff --git a/web/core/components/workspace/sidebar/user-menu.tsx b/web/core/components/workspace/sidebar/user-menu.tsx index 4c78e3e50..4fef08e25 100644 --- a/web/core/components/workspace/sidebar/user-menu.tsx +++ b/web/core/components/workspace/sidebar/user-menu.tsx @@ -7,7 +7,7 @@ import { useParams, usePathname } from "next/navigation"; // ui import { Tooltip } from "@plane/ui"; // components -import { NotificationPopover } from "@/components/notifications"; +import { NotificationAppSidebarOption } from "@/components/workspace-notifications"; // constants import { SIDEBAR_USER_MENU_ITEMS } from "@/constants/dashboard"; import { SIDEBAR_CLICKED } from "@/constants/event-tracker"; @@ -61,7 +61,7 @@ export const SidebarUserMenu = observer(() => { >
    { {!sidebarCollapsed &&

    {link.label}

    } + {link.key === "notifications" && ( + + )}
    ) )} -
    ); }); diff --git a/web/core/constants/dashboard.ts b/web/core/constants/dashboard.ts index 25306911f..056941ef9 100644 --- a/web/core/constants/dashboard.ts +++ b/web/core/constants/dashboard.ts @@ -2,7 +2,7 @@ import { linearGradientDef } from "@nivo/core"; // icons -import { BarChart2, Briefcase, CheckCircle, Home, Settings } from "lucide-react"; +import { BarChart2, Bell, Briefcase, CheckCircle, Home, Settings } from "lucide-react"; // types import { TIssuesListTypes, TStateGroups } from "@plane/types"; // ui @@ -317,4 +317,12 @@ export const SIDEBAR_USER_MENU_ITEMS: { highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/`, Icon: Home, }, + { + key: "notifications", + label: "Notifications", + href: `/notifications`, + access: EUserWorkspaceRoles.GUEST, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/notifications`, + Icon: Bell, + }, ]; diff --git a/web/core/constants/empty-state.ts b/web/core/constants/empty-state.ts index 0a71ccc62..cad133dd0 100644 --- a/web/core/constants/empty-state.ts +++ b/web/core/constants/empty-state.ts @@ -80,6 +80,8 @@ export enum EmptyStateType { ISSUE_RELATION_EMPTY_STATE = "issue-relation-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_CREATED_EMPTY_STATE = "notification-created-empty-state", NOTIFICATION_SUBSCRIBED_EMPTY_STATE = "notification-subscribed-empty-state", @@ -593,13 +595,24 @@ const emptyStateDetails = { 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]: { key: EmptyStateType.NOTIFICATION_MY_ISSUE_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_CREATED_EMPTY_STATE]: { key: EmptyStateType.NOTIFICATION_CREATED_EMPTY_STATE, 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", path: "/empty-state/search/archive", }, + [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", diff --git a/web/core/constants/fetch-keys.ts b/web/core/constants/fetch-keys.ts index 5d0fb7717..ec5de760f 100644 --- a/web/core/constants/fetch-keys.ts +++ b/web/core/constants/fetch-keys.ts @@ -1,5 +1,4 @@ -import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "@plane/types"; -import { objToQueryParams } from "@/helpers/string.helper"; +import { IAnalyticsParams, IJiraMetadata } from "@plane/types"; const paramsToKey = (params: any) => { const { @@ -246,41 +245,6 @@ export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) => export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial) => `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 export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) => `USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`; diff --git a/web/core/constants/notification.ts b/web/core/constants/notification.ts index 04f803151..fe4f0a026 100644 --- a/web/core/constants/notification.ts +++ b/web/core/constants/notification.ts @@ -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", - 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", - 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", - 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", - 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", - 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", - value: null, + value: undefined, }, ]; diff --git a/web/core/hooks/store/index.ts b/web/core/hooks/store/index.ts index 0b02ae670..c20f42087 100644 --- a/web/core/hooks/store/index.ts +++ b/web/core/hooks/store/index.ts @@ -33,3 +33,4 @@ export * from "./use-app-theme"; export * from "./use-command-palette"; export * from "./use-router-params"; export * from "./estimates"; +export * from "./notifications"; diff --git a/web/core/hooks/store/notifications/index.ts b/web/core/hooks/store/notifications/index.ts new file mode 100644 index 000000000..07bcca1cf --- /dev/null +++ b/web/core/hooks/store/notifications/index.ts @@ -0,0 +1,2 @@ +export * from "./use-workspace-notifications"; +export * from "./use-notification"; diff --git a/web/core/hooks/store/notifications/use-notification.ts b/web/core/hooks/store/notifications/use-notification.ts new file mode 100644 index 000000000..9df87dd5c --- /dev/null +++ b/web/core/hooks/store/notifications/use-notification.ts @@ -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] ?? {}; +}; diff --git a/web/core/hooks/store/notifications/use-workspace-notifications.ts b/web/core/hooks/store/notifications/use-workspace-notifications.ts new file mode 100644 index 000000000..f882d6716 --- /dev/null +++ b/web/core/hooks/store/notifications/use-workspace-notifications.ts @@ -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; +}; diff --git a/web/core/hooks/use-issue-notification-subscription.tsx b/web/core/hooks/use-issue-notification-subscription.tsx deleted file mode 100644 index 2770f00f5..000000000 --- a/web/core/hooks/use-issue-notification-subscription.tsx +++ /dev/null @@ -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; diff --git a/web/core/hooks/use-user-notifications.tsx b/web/core/hooks/use-user-notifications.tsx deleted file mode 100644 index 03e1eb355..000000000 --- a/web/core/hooks/use-user-notifications.tsx +++ /dev/null @@ -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(false); - const [archived, setArchived] = useState(false); - const [readNotification, setReadNotification] = useState(false); - const [fetchNotifications, setFetchNotifications] = useState(false); - const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState(null); - const [selectedTab, setSelectedTab] = useState("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; diff --git a/web/core/services/issue/issue.service.ts b/web/core/services/issue/issue.service.ts index 7cded4633..69ef1ed5c 100644 --- a/web/core/services/issue/issue.service.ts +++ b/web/core/services/issue/issue.service.ts @@ -1,5 +1,13 @@ // 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 import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -272,4 +280,35 @@ export class IssueService extends APIService { 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 { + 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 { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/core/services/notification.service.ts b/web/core/services/notification.service.ts deleted file mode 100644 index ff1cc8177..000000000 --- a/web/core/services/notification.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 - ): Promise { - 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 { - 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 { - 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 { - 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 { - return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getNotifications(url: string): Promise { - return this.get(url) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async markAllNotificationsAsRead(workspaceSlug: string, payload: IMarkAllAsReadPayload): Promise { - return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/mark-all-read/`, { - ...payload, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } -} diff --git a/web/core/services/workspace-notification.service.ts b/web/core/services/workspace-notification.service.ts new file mode 100644 index 000000000..933b82063 --- /dev/null +++ b/web/core/services/workspace-notification.service.ts @@ -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 { + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/web/core/store/issue/issue-details/subscription.store.ts b/web/core/store/issue/issue-details/subscription.store.ts index 1326d705e..76757f644 100644 --- a/web/core/store/issue/issue-details/subscription.store.ts +++ b/web/core/store/issue/issue-details/subscription.store.ts @@ -1,7 +1,7 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; // services -import { NotificationService } from "@/services/notification.service"; +import { IssueService } from "@/services/issue/issue.service"; // types import { IIssueDetail } from "./root.store"; @@ -25,7 +25,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { // root store rootIssueDetail: IIssueDetail; // services - notificationService; + issueService; constructor(rootStore: IIssueDetail) { makeObservable(this, { @@ -40,7 +40,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { // root store this.rootIssueDetail = rootStore; // services - this.notificationService = new NotificationService(); + this.issueService = new IssueService(); } // helper methods @@ -62,7 +62,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const subscription = await this.notificationService.getIssueNotificationSubscriptionStatus( + const subscription = await this.issueService.getIssueNotificationSubscriptionStatus( workspaceSlug, projectId, issueId @@ -85,7 +85,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { set(this.subscriptionMap, [issueId, currentUserId], true); }); - await this.notificationService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId); + await this.issueService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId); } catch (error) { this.fetchSubscriptions(workspaceSlug, projectId, issueId); throw error; @@ -101,7 +101,7 @@ export class IssueSubscriptionStore implements IIssueSubscriptionStore { set(this.subscriptionMap, [issueId, currentUserId], false); }); - await this.notificationService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId); + await this.issueService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId); } catch (error) { this.fetchSubscriptions(workspaceSlug, projectId, issueId); throw error; diff --git a/web/core/store/notifications/notification.ts b/web/core/store/notifications/notification.ts new file mode 100644 index 000000000..dabb25d97 --- /dev/null +++ b/web/core/store/notifications/notification.ts @@ -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) => void; + // actions + updateNotification: (workspaceSlug: string, payload: Partial) => Promise; + markNotificationAsRead: (workspaceSlug: string) => Promise; + markNotificationAsUnRead: (workspaceSlug: string) => Promise; + archiveNotification: (workspaceSlug: string) => Promise; + unArchiveNotification: (workspaceSlug: string) => Promise; + snoozeNotification: (workspaceSlug: string, snoozeTill: Date) => Promise; + unSnoozeNotification: (workspaceSlug: string) => Promise; +} + +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) => { + Object.entries(notification).forEach(([key, value]) => { + if (key in this) { + set(this, key, value); + } + }); + }; + + // actions + /** + * @description update notification + * @param { string } workspaceSlug + * @param { Partial } payload + * @returns { TNotification | undefined } + */ + updateNotification = async ( + workspaceSlug: string, + payload: Partial + ): Promise => { + 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 => { + if (!this.id) return undefined; + + const currentNotificationReadAt = this.read_at; + try { + const payload: Partial = { + 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 => { + if (!this.id) return undefined; + + const currentNotificationReadAt = this.read_at; + try { + const payload: Partial = { + 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 => { + if (!this.id) return undefined; + + const currentNotificationArchivedAt = this.archived_at; + try { + const payload: Partial = { + 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 => { + if (!this.id) return undefined; + + const currentNotificationArchivedAt = this.archived_at; + try { + const payload: Partial = { + 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 => { + if (!this.id) return undefined; + + const currentNotificationSnoozeTill = this.snoozed_till; + try { + const payload: Partial = { + 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 => { + if (!this.id) return undefined; + + const currentNotificationSnoozeTill = this.snoozed_till; + try { + const payload: Partial = { + 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; + } + }; +} diff --git a/web/core/store/notifications/workspace-notifications.store.ts b/web/core/store/notifications/workspace-notifications.store.ts new file mode 100644 index 000000000..1bdc30451 --- /dev/null +++ b/web/core/store/notifications/workspace-notifications.store.ts @@ -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; // notification_id -> notification + currentNotificationTab: TNotificationTab; + paginationInfo: Omit | undefined; + filters: TNotificationFilter; + // computed + totalUnreadNotificationsCount: number; + // computed functions + notificationIdsByWorkspaceId: (workspaceId: string) => string[] | undefined; + // helper actions + mutateNotifications: (notifications: TNotification[]) => void; + updateFilters: (key: T, value: TNotificationFilter[T]) => void; + updateBulkFilters: (filters: Partial) => void; + // actions + setCurrentNotificationTab: (tab: TNotificationTab) => void; + getUnreadNotificationsCount: (workspaceSlug: string) => Promise; + getNotifications: ( + workspaceSlug: string, + loader?: TNotificationLoader, + queryCursorType?: TNotificationQueryParamType + ) => Promise; + markAllNotificationsAsRead: (workspaceId: string) => Promise; +} + +export class WorkspaceNotificationStore implements IWorkspaceNotificationStore { + // constants + paginatedCount = 30; + // observables + loader: TNotificationLoader = undefined; + unreadNotificationsCount: TUnreadNotificationsCount | undefined = undefined; + notifications: Record = {}; + currentNotificationTab: TNotificationTab = ENotificationTab.ALL; + paginationInfo: Omit | 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 = (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 } filters + */ + updateBulkFilters = (filters: Partial) => { + 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 => { + 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 => { + 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 => { + 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)); + } + }; +} diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index 9bdf6d148..8cf126ead 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -15,6 +15,7 @@ import { IMemberRootStore, MemberRootStore } from "./member"; import { IModuleStore, ModulesStore } from "./module.store"; import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.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 { IProjectRootStore, ProjectRootStore } from "./project"; import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; @@ -50,6 +51,7 @@ export class CoreRootStore { projectInbox: IProjectInboxStore; projectEstimate: IProjectEstimateStore; multipleSelect: IMultipleSelectStore; + workspaceNotification: IWorkspaceNotificationStore; constructor() { this.router = new RouterStore(); @@ -75,6 +77,7 @@ export class CoreRootStore { this.projectInbox = new ProjectInboxStore(this); this.projectPages = new ProjectPageStore(this); this.projectEstimate = new ProjectEstimateStore(this); + this.workspaceNotification = new WorkspaceNotificationStore(this); } resetOnSignOut() { @@ -103,5 +106,6 @@ export class CoreRootStore { this.projectPages = new ProjectPageStore(this); this.multipleSelect = new MultipleSelectStore(); this.projectEstimate = new ProjectEstimateStore(this); + this.workspaceNotification = new WorkspaceNotificationStore(this); } }