diff --git a/apps/app/components/icons/archive-icon.tsx b/apps/app/components/icons/archive-icon.tsx new file mode 100644 index 000000000..6c9c791fb --- /dev/null +++ b/apps/app/components/icons/archive-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ArchiveIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/bell-icon.tsx b/apps/app/components/icons/bell-icon.tsx new file mode 100644 index 000000000..4aafb702e --- /dev/null +++ b/apps/app/components/icons/bell-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const BellNotificationIcon: React.FC = ({ + width = "24", + height = "24", + color = "rgb(var(--color-text-200))", + className, +}) => ( + + + +); diff --git a/apps/app/components/icons/clock-icon.tsx b/apps/app/components/icons/clock-icon.tsx new file mode 100644 index 000000000..3d2273364 --- /dev/null +++ b/apps/app/components/icons/clock-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const ClockIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index db7aad041..183b20c97 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -82,3 +82,9 @@ export * from "./command-icon"; export * from "./color-picker-icon"; export * from "./inbox-icon"; export * from "./stacked-layers-horizontal-icon"; +export * from "./sort-icon"; +export * from "./x-mark-icon"; +export * from "./archive-icon"; +export * from "./clock-icon"; +export * from "./bell-icon"; +export * from "./single-comment-icon"; diff --git a/apps/app/components/icons/single-comment-icon.tsx b/apps/app/components/icons/single-comment-icon.tsx new file mode 100644 index 000000000..b770124a1 --- /dev/null +++ b/apps/app/components/icons/single-comment-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const SingleCommentCard: React.FC = ({ + width = "24", + height = "24", + className, + color, +}) => ( + + + +); diff --git a/apps/app/components/icons/sort-icon.tsx b/apps/app/components/icons/sort-icon.tsx new file mode 100644 index 000000000..955cdadd5 --- /dev/null +++ b/apps/app/components/icons/sort-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const SortIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/icons/x-mark-icon.tsx b/apps/app/components/icons/x-mark-icon.tsx new file mode 100644 index 000000000..afebc8273 --- /dev/null +++ b/apps/app/components/icons/x-mark-icon.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const XMarkIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + +); diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 13a9c6395..8b1ab8023 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // react-hook-form -import { useForm, Controller, UseFormWatch, Control } from "react-hook-form"; +import { useForm, Controller, UseFormWatch } from "react-hook-form"; // react-color import { TwitterPicker } from "react-color"; // headless ui @@ -13,6 +13,7 @@ import { Popover, Listbox, Transition } from "@headlessui/react"; // hooks import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; +import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription"; // services import issuesService from "services/issues.service"; import modulesService from "services/modules.service"; @@ -96,6 +97,9 @@ export const IssueDetailsSidebar: React.FC = ({ const { user } = useUserAuth(); + const { loading, handleSubscribe, handleUnsubscribe, subscribed } = + useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId); + const { memberRole } = useProjectMyMembership(); const { setToastAlert } = useToast(); @@ -287,6 +291,16 @@ export const IssueDetailsSidebar: React.FC = ({ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
+ {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + ))} +
+ + + ); +}; diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx new file mode 100644 index 000000000..334a3acda --- /dev/null +++ b/apps/app/components/notifications/notification-popover.tsx @@ -0,0 +1,245 @@ +import React, { Fragment } from "react"; + +import Image from "next/image"; + +// hooks +import useTheme from "hooks/use-theme"; +// icons +import { + XMarkIcon, + ArchiveIcon, + ClockIcon, + SortIcon, + BellNotificationIcon, +} from "components/icons"; + +import { Popover, Transition, Menu } from "@headlessui/react"; +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; + +// hooks +import useUserNotification from "hooks/use-user-notifications"; + +// components +import { Spinner } from "components/ui"; +import { SnoozeNotificationModal, NotificationCard } from "components/notifications"; + +// type +import type { NotificationType } from "types"; + +const notificationTabs: Array<{ + label: string; + value: NotificationType; +}> = [ + { + label: "My Issues", + value: "assigned", + }, + { + label: "Created by me", + value: "created", + }, + { + label: "Subscribed", + value: "watching", + }, +]; + +export const NotificationPopover = () => { + const { + notifications, + archived, + readNotification, + selectedNotificationForSnooze, + selectedTab, + setArchived, + setReadNotification, + setSelectedNotificationForSnooze, + setSelectedTab, + setSnoozed, + snoozed, + notificationsMutate, + markNotificationArchivedStatus, + markNotificationReadStatus, + } = useUserNotification(); + + // theme context + const { collapsed: sidebarCollapse } = useTheme(); + + return ( + <> + setSelectedNotificationForSnooze(null)} + notificationId={selectedNotificationForSnooze} + onSuccess={() => { + notificationsMutate(); + setSelectedNotificationForSnooze(null); + }} + /> + + {({ open: isActive, close: closePopover }) => ( + <> + + + + {sidebarCollapse ? null : Notifications} + + + +
+

+ Notifications +

+
+ + + + +
+
+ +
+ {snoozed || archived || readNotification ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} +
+ +
+ {notifications ? ( + notifications.length > 0 ? ( + notifications.map((notification) => ( + + )) + ) : ( +
+ Empty +

+ You{"'"}re updated with all the notifications +

+

+ You have read all the notifications. +

+
+ ) + ) : ( +
+ +
+ )} +
+
+
+ + )} +
+ + ); +}; diff --git a/apps/app/components/notifications/select-snooze-till-modal.tsx b/apps/app/components/notifications/select-snooze-till-modal.tsx new file mode 100644 index 000000000..4d50ebf6f --- /dev/null +++ b/apps/app/components/notifications/select-snooze-till-modal.tsx @@ -0,0 +1,317 @@ +import React, { Fragment } from "react"; + +// next +import { useRouter } from "next/router"; + +// react hook form +import { useForm, Controller } from "react-hook-form"; + +import { Transition, Dialog, Listbox } from "@headlessui/react"; +import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid"; + +// date helper +import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper"; + +// services +import userNotificationServices from "services/notifications.service"; + +// hooks +import useToast from "hooks/use-toast"; + +// components +import { PrimaryButton, SecondaryButton } from "components/ui"; + +// icons +import { XMarkIcon } from "components/icons"; + +type SnoozeModalProps = { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + notificationId: string | null; +}; + +const dates = getDatesAfterCurrentDate(); +const timeStamps = getTimestampAfterCurrentTime(); + +export const SnoozeNotificationModal: React.FC = (props) => { + const { isOpen, onClose, notificationId, onSuccess } = props; + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { + formState: { isSubmitting }, + reset, + handleSubmit, + control, + } = useForm(); + + const onSubmit = async (formData: any) => { + if (!workspaceSlug || !notificationId) return; + + const dateTime = new Date( + `${formData.date.toLocaleDateString()} ${formData.time.toLocaleTimeString()}` + ); + + await userNotificationServices + .patchUserNotification(workspaceSlug.toString(), notificationId, { + snoozed_till: dateTime, + }) + .then(() => { + onClose(); + onSuccess(); + setToastAlert({ + title: "Notification snoozed", + message: "Notification snoozed successfully", + type: "success", + }); + }); + }; + + const handleClose = () => { + onClose(); + const timeout = setTimeout(() => { + reset(); + clearTimeout(timeout); + }, 500); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ + Customize Snooze Time + + +
+ +
+
+ +
+
+ ( + + {({ open }) => ( + <> +
+ + + + {value?.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) || "Select Time"} + + + + + + + + + {timeStamps.map((time, index) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active + ? "bg-custom-primary-100 text-custom-text-100" + : "text-custom-text-700" + }` + } + value={time.value} + > + {({ selected, active }) => ( + <> +
+ + {time.label} + +
+ + {selected ? ( + + + ) : null} + + )} +
+ ))} +
+
+
+ + )} +
+ )} + /> +
+
+ ( + + {({ open }) => ( + <> +
+ + + + {value?.toLocaleDateString([], { + day: "numeric", + month: "long", + year: "numeric", + }) || "Select Date"} + + + + + + + + + {dates.map((date, index) => ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active + ? "bg-custom-primary-100 text-custom-text-100" + : "text-custom-text-700" + }` + } + value={date.value} + > + {({ selected, active }) => ( + <> +
+ + {date.label} + +
+ + {selected ? ( + + + ) : null} + + )} +
+ ))} +
+
+
+ + )} +
+ )} + /> +
+
+ +
+
+ Cancel + + Submit + +
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/app/components/workspace/sidebar-menu.tsx b/apps/app/components/workspace/sidebar-menu.tsx index b86aa6610..2114f502d 100644 --- a/apps/app/components/workspace/sidebar-menu.tsx +++ b/apps/app/components/workspace/sidebar-menu.tsx @@ -1,13 +1,38 @@ import React from "react"; -import { useRouter } from "next/router"; import Link from "next/link"; +import { useRouter } from "next/router"; // hooks import useTheme from "hooks/use-theme"; // icons import { ChartBarIcon } from "@heroicons/react/24/outline"; -import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon, SettingIcon } from "components/icons"; +import { GridViewIcon, AssignmentClipboardIcon, TickMarkIcon } from "components/icons"; + +import { NotificationPopover } from "components/notifications"; + +const workspaceLinks = (workspaceSlug: string) => [ + { + icon: GridViewIcon, + name: "Dashboard", + href: `/${workspaceSlug}`, + }, + { + icon: ChartBarIcon, + name: "Analytics", + href: `/${workspaceSlug}/analytics`, + }, + { + icon: AssignmentClipboardIcon, + name: "Projects", + href: `/${workspaceSlug}/projects`, + }, + { + icon: TickMarkIcon, + name: "My Issues", + href: `/${workspaceSlug}/me/my-issues`, + }, +]; export const WorkspaceSidebarMenu = () => { const router = useRouter(); @@ -16,34 +41,6 @@ export const WorkspaceSidebarMenu = () => { // theme context const { collapsed: sidebarCollapse } = useTheme(); - const workspaceLinks = (workspaceSlug: string) => [ - { - icon: GridViewIcon, - name: "Dashboard", - href: `/${workspaceSlug}`, - }, - { - icon: ChartBarIcon, - name: "Analytics", - href: `/${workspaceSlug}/analytics`, - }, - { - icon: AssignmentClipboardIcon, - name: "Projects", - href: `/${workspaceSlug}/projects`, - }, - { - icon: TickMarkIcon, - name: "My Issues", - href: `/${workspaceSlug}/me/my-issues`, - }, - { - icon: SettingIcon, - name: "Settings", - href: `/${workspaceSlug}/settings`, - }, - ]; - return (
{workspaceLinks(workspaceSlug as string).map((link, index) => { @@ -80,6 +77,8 @@ export const WorkspaceSidebarMenu = () => { ); })} + +
); }; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 7e77e6dc2..7e52616ab 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,4 +1,4 @@ -import { IAnalyticsParams, IJiraMetadata } from "types"; +import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types"; const paramsToKey = (params: any) => { const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params; @@ -206,3 +206,21 @@ export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial { + 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()}`; diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index b513b38da..c28d326ee 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -92,7 +92,7 @@ export const timeAgo = (time: any) => { list_choice = 2; } var i = 0, - format; + format: any[]; while ((format = time_formats[i++])) if (seconds < format[0]) { if (typeof format[2] == "string") return format[list_choice]; @@ -101,6 +101,35 @@ export const timeAgo = (time: any) => { return time; }; +export const formatDateDistance = (date: string | Date) => { + const today = new Date(); + const eventDate = new Date(date); + const timeDiff = Math.abs(eventDate.getTime() - today.getTime()); + const days = Math.ceil(timeDiff / (1000 * 3600 * 24)); + + if (days < 1) { + const hours = Math.ceil(timeDiff / (1000 * 3600)); + if (hours < 1) { + const minutes = Math.ceil(timeDiff / (1000 * 60)); + if (minutes < 1) { + return "Just now"; + } else { + return `${minutes}m`; + } + } else { + return `${hours}h`; + } + } else if (days < 7) { + return `${days}d`; + } else if (days < 30) { + return `${Math.floor(days / 7)}w`; + } else if (days < 365) { + return `${Math.floor(days / 30)}m`; + } else { + return `${Math.floor(days / 365)}y`; + } +}; + export const getDateRangeStatus = ( startDate: string | null | undefined, endDate: string | null | undefined @@ -230,3 +259,60 @@ export const renderLongDateFormat = (dateString: string) => { } return `${day}${suffix} ${monthName} ${year}`; }; + +/** + * + * @returns {Array} Array of time objects with label and value as keys + */ + +export const getTimestampAfterCurrentTime = (): Array<{ + label: string; + value: Date; +}> => { + const current = new Date(); + const time = []; + for (let i = 0; i < 24; i++) { + const newTime = new Date(current.getTime() + i * 60 * 60 * 1000); + time.push({ + label: newTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + value: newTime, + }); + } + return time; +}; + +/** + * @returns {Array} Array of date objects with label and value as keys + * @description Returns an array of date objects starting from current date to 7 days after + */ + +export const getDatesAfterCurrentDate = (): Array<{ + label: string; + value: Date; +}> => { + const current = new Date(); + const date = []; + for (let i = 0; i < 7; i++) { + const newDate = new Date(current.getTime() + i * 24 * 60 * 60 * 1000); + date.push({ + label: newDate.toLocaleDateString([], { + day: "numeric", + month: "long", + year: "numeric", + }), + value: newDate, + }); + } + return date; +}; + +/** + * @returns {boolean} true if date is valid + * @description Returns true if date is valid + * @param {string} date + * @example checkIfStringIsDate("2021-01-01") // true + * @example checkIfStringIsDate("2021-01-32") // false + */ + +export const checkIfStringIsDate = (date: string): boolean => + new Date(date).toString() !== "Invalid Date"; diff --git a/apps/app/helpers/string.helper.ts b/apps/app/helpers/string.helper.ts index a5fc05e78..2436a8d12 100644 --- a/apps/app/helpers/string.helper.ts +++ b/apps/app/helpers/string.helper.ts @@ -118,3 +118,19 @@ export const getFirstCharacters = (str: string) => { return words[0].charAt(0) + words[1].charAt(0); } }; + +/** + * @description: This function will remove all the HTML tags from the string + * @param {string} html + * @return {string} + * @example: + * const html = "

Some text

"; + * const text = stripHTML(html); + * console.log(text); // Some text + */ + +export const stripHTML = (html: string) => { + const tmp = document.createElement("DIV"); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ""; +}; diff --git a/apps/app/hooks/use-issue-notification-subscription.tsx b/apps/app/hooks/use-issue-notification-subscription.tsx new file mode 100644 index 000000000..38bf8bf22 --- /dev/null +++ b/apps/app/hooks/use-issue-notification-subscription.tsx @@ -0,0 +1,76 @@ +import { useCallback } from "react"; + +import useSWR from "swr"; + +// hooks +import useUserAuth from "hooks/use-user-auth"; +// services +import userNotificationServices from "services/notifications.service"; + +const useUserIssueNotificationSubscription = ( + workspaceSlug?: string | string[] | null, + projectId?: string | string[] | null, + issueId?: string | string[] | null +) => { + const { user } = useUserAuth(); + + 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; + + userNotificationServices + .unsubscribeFromIssueNotifications( + workspaceSlug as string, + projectId as string, + issueId as string + ) + .then(() => { + mutate({ + subscribed: false, + }); + }); + }, [workspaceSlug, projectId, issueId, mutate]); + + const handleSubscribe = useCallback(() => { + console.log(workspaceSlug, projectId, issueId, user); + + if (!workspaceSlug || !projectId || !issueId || !user) return; + + userNotificationServices + .subscribeToIssueNotifications( + workspaceSlug as string, + projectId as string, + issueId as string, + { + subscriber: user.id, + } + ) + .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/apps/app/hooks/use-user-notifications.tsx b/apps/app/hooks/use-user-notifications.tsx new file mode 100644 index 000000000..8aaeadf11 --- /dev/null +++ b/apps/app/hooks/use-user-notifications.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +// swr +import useSWR from "swr"; + +// services +import userNotificationServices from "services/notifications.service"; + +// fetch-keys +import { USER_WORKSPACE_NOTIFICATIONS } from "constants/fetch-keys"; + +// type +import type { NotificationType } from "types"; + +const useUserNotification = () => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const [snoozed, setSnoozed] = useState(false); + const [archived, setArchived] = useState(false); + const [readNotification, setReadNotification] = useState(false); + const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState( + null + ); + const [selectedTab, setSelectedTab] = useState("assigned"); + + const { data: notifications, mutate: notificationsMutate } = useSWR( + workspaceSlug + ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), { + type: selectedTab, + snoozed, + archived, + read: readNotification, + }) + : null, + workspaceSlug + ? () => + userNotificationServices.getUserNotifications(workspaceSlug.toString(), { + type: selectedTab, + snoozed, + archived, + read: readNotification, + }) + : null + ); + + const markNotificationReadStatus = async (notificationId: string) => { + if (!workspaceSlug) return; + const isRead = + notifications?.find((notification) => notification.id === notificationId)?.read_at !== null; + + if (isRead) { + await userNotificationServices + .markUserNotificationAsUnread(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.map((prevNotification) => { + if (prevNotification.id === notificationId) { + return { + ...prevNotification, + read_at: null, + }; + } + return prevNotification; + }) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } else { + await userNotificationServices + .markUserNotificationAsRead(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.map((prevNotification) => { + if (prevNotification.id === notificationId) { + return { + ...prevNotification, + read_at: new Date(), + }; + } + return prevNotification; + }) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } + }; + + const markNotificationArchivedStatus = async (notificationId: string) => { + if (!workspaceSlug) return; + const isArchived = + notifications?.find((notification) => notification.id === notificationId)?.archived_at !== + null; + + if (isArchived) { + await userNotificationServices + .markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate(); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } else { + await userNotificationServices + .markUserNotificationAsArchived(workspaceSlug.toString(), notificationId) + .then(() => { + notificationsMutate((prev) => + prev?.filter((prevNotification) => prevNotification.id !== notificationId) + ); + }) + .catch(() => { + throw new Error("Something went wrong"); + }); + } + }; + + return { + notifications, + notificationsMutate, + markNotificationReadStatus, + markNotificationArchivedStatus, + snoozed, + setSnoozed, + archived, + setArchived, + readNotification, + setReadNotification, + selectedNotificationForSnooze, + setSelectedNotificationForSnooze, + selectedTab, + setSelectedTab, + }; +}; + +export default useUserNotification; diff --git a/apps/app/public/empty-state/empty-notification.svg b/apps/app/public/empty-state/empty-notification.svg new file mode 100644 index 000000000..700a1552f --- /dev/null +++ b/apps/app/public/empty-state/empty-notification.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/app/services/notifications.service.ts b/apps/app/services/notifications.service.ts new file mode 100644 index 000000000..8a9cc8e4c --- /dev/null +++ b/apps/app/services/notifications.service.ts @@ -0,0 +1,162 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +// types +import { IUserNotification, INotificationParams } from "types"; + +class UserNotificationsServices extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + 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, + data: { + subscriber: string; + } + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-subscribers/`, + data + ) + .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; + }); + } +} + +const userNotificationServices = new UserNotificationsServices(); + +export default userNotificationServices; diff --git a/apps/app/tailwind.config.js b/apps/app/tailwind.config.js index d9897cee9..dbb4b940c 100644 --- a/apps/app/tailwind.config.js +++ b/apps/app/tailwind.config.js @@ -1,4 +1,4 @@ -const convertToRGB = (variableName) => `rgb(var(${variableName}))`; +const convertToRGB = (variableName) => `rgba(var(${variableName}))`; module.exports = { darkMode: "class", diff --git a/apps/app/types/index.d.ts b/apps/app/types/index.d.ts index a8dcce3bc..fdb612604 100644 --- a/apps/app/types/index.d.ts +++ b/apps/app/types/index.d.ts @@ -15,6 +15,7 @@ export * from "./importer"; export * from "./inbox"; export * from "./analytics"; export * from "./calendar"; +export * from "./notifications"; export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object diff --git a/apps/app/types/notifications.d.ts b/apps/app/types/notifications.d.ts new file mode 100644 index 000000000..cc7b6f1ed --- /dev/null +++ b/apps/app/types/notifications.d.ts @@ -0,0 +1,56 @@ +import type { IUserLite } from "./users"; + +export interface IUserNotification { + id: string; + created_at: Date; + updated_at: Date; + data: Data; + entity_identifier: string; + entity_name: string; + title: string; + message: null; + message_html: string; + message_stripped: null; + sender: string; + read_at: Date | null; + archived_at: Date | null; + snoozed_till: Date | null; + created_by: null; + updated_by: null; + workspace: string; + project: string; + triggered_by: string; + triggered_by_details: IUserLite; + receiver: string; +} + +export interface Data { + issue: IIssueLite; + issue_activity: { + actor: string; + field: string; + id: string; + issue_comment: string | null; + new_value: string; + old_value: string; + verb: "created" | "updated"; + }; +} + +export interface IIssueLite { + id: string; + name: string; + identifier: string; + state_name: string; + sequence_id: number; + state_group: string; +} + +export type NotificationType = "created" | "assigned" | "watching"; + +export interface INotificationParams { + snoozed?: boolean; + type?: NotificationType; + archived?: boolean; + read?: boolean; +}