feat: user issue notifications (#1523)

* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

* fix: pushing local settings

* feat: notification workflow setup and made basic UI

* style: improved UX with toast alerts and other interactions

refactor: changed classnames according to new theme structure, changed all icons to material icons

* feat: showing un-read notification count

* feat: not showing 'subscribe' button on issue created by user & assigned to user

not showing 'Create by you' for view & guest of the workspace

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Dakshesh Jain 2023-07-18 12:07:55 +05:30 committed by GitHub
parent 6e9f3971a5
commit 16a7bd3bda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 4665 additions and 32 deletions

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const ArchiveIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.75 19.5C5.41667 19.5 5.125 19.375 4.875 19.125C4.625 18.875 4.5 18.5833 4.5 18.25V7.35417C4.5 7.14583 4.52083 6.96875 4.5625 6.82292C4.60417 6.67708 4.68056 6.54167 4.79167 6.41667L5.95833 4.83333C6.06944 4.70833 6.19792 4.62153 6.34375 4.57292C6.48958 4.52431 6.6624 4.5 6.86221 4.5H17.1378C17.3376 4.5 17.5069 4.52431 17.6458 4.57292C17.7847 4.62153 17.9097 4.70833 18.0208 4.83333L19.2083 6.41667C19.3194 6.54167 19.3958 6.67708 19.4375 6.82292C19.4792 6.96875 19.5 7.14583 19.5 7.35417V18.25C19.5 18.5833 19.375 18.875 19.125 19.125C18.875 19.375 18.5833 19.5 18.25 19.5H5.75ZM6.10417 6.70833H17.875L17.1165 5.75H6.85417L6.10417 6.70833ZM5.75 7.95833V18.25H18.25V7.95833H5.75ZM12 16.375L15.25 13.125L14.4167 12.2917L12.625 14.0833V9.89583H11.375V14.0833L9.58333 12.2917L8.75 13.125L12 16.375Z"
fill={color ? color : "currentColor"}
/>
</svg>
);

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const BellNotificationIcon: React.FC<Props> = ({
width = "24",
height = "24",
color = "rgb(var(--color-text-200))",
className,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.35425 17.4154C4.15946 17.4154 3.99618 17.3491 3.8644 17.2166C3.73263 17.084 3.66675 16.9198 3.66675 16.7239C3.66675 16.5279 3.73263 16.365 3.8644 16.2352C3.99618 16.1053 4.15946 16.0404 4.35425 16.0404H5.59175V9.02786C5.59175 7.77509 5.96987 6.64071 6.72612 5.62474C7.48237 4.60877 8.47925 3.97092 9.71675 3.7112V3.04661C9.71675 2.69523 9.84099 2.40495 10.0895 2.17578C10.338 1.94661 10.6397 1.83203 10.9947 1.83203C11.3497 1.83203 11.6532 1.94661 11.9053 2.17578C12.1574 2.40495 12.2834 2.69523 12.2834 3.04661V3.7112C13.5209 3.97092 14.5216 4.60877 15.2855 5.62474C16.0494 6.64071 16.4313 7.77509 16.4313 9.02786V16.0404H17.6459C17.8407 16.0404 18.004 16.1066 18.1358 16.2392C18.2675 16.3717 18.3334 16.536 18.3334 16.7319C18.3334 16.9278 18.2675 17.0907 18.1358 17.2206C18.004 17.3504 17.8407 17.4154 17.6459 17.4154H4.35425ZM11.0001 20.1654C10.5112 20.1654 10.0834 19.9859 9.71675 19.6268C9.35008 19.2678 9.16675 18.8362 9.16675 18.332H12.8334C12.8334 18.8362 12.6539 19.2678 12.2949 19.6268C11.9358 19.9859 11.5042 20.1654 11.0001 20.1654ZM6.96675 16.0404H15.0563V9.02786C15.0563 7.88203 14.6706 6.91571 13.899 6.12891C13.1275 5.3421 12.1727 4.9487 11.0345 4.9487C9.89626 4.9487 8.93376 5.3421 8.14696 6.12891C7.36015 6.91571 6.96675 7.88203 6.96675 9.02786V16.0404Z"
fill={color}
/>
</svg>
);

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const ClockIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.6876 11.7513V8.1888C12.6876 8.00825 12.6286 7.85894 12.5105 7.74088C12.3924 7.62283 12.2431 7.5638 12.0626 7.5638C11.882 7.5638 11.7327 7.62283 11.6147 7.74088C11.4966 7.85894 11.4376 8.00825 11.4376 8.1888V12.0013C11.4376 12.0846 11.4515 12.161 11.4792 12.2305C11.507 12.2999 11.5487 12.3694 11.6042 12.4388L14.6042 15.543C14.7292 15.6819 14.8855 15.7478 15.073 15.7409C15.2605 15.7339 15.4167 15.668 15.5417 15.543C15.6667 15.418 15.7292 15.2652 15.7292 15.0846C15.7292 14.9041 15.6667 14.7513 15.5417 14.6263L12.6876 11.7513ZM12.0001 20.3346C10.8612 20.3346 9.7848 20.1159 8.77091 19.6784C7.75703 19.2409 6.87161 18.6437 6.11466 17.8867C5.35772 17.1298 4.7605 16.2444 4.323 15.2305C3.8855 14.2166 3.66675 13.1402 3.66675 12.0013C3.66675 10.8624 3.8855 9.78602 4.323 8.77213C4.7605 7.75825 5.35772 6.87283 6.11466 6.11589C6.87161 5.35894 7.75703 4.76172 8.77091 4.32422C9.7848 3.88672 10.8612 3.66797 12.0001 3.66797C13.139 3.66797 14.2154 3.88672 15.2292 4.32422C16.2431 4.76172 17.1286 5.35894 17.8855 6.11589C18.6424 6.87283 19.2397 7.75825 19.6772 8.77213C20.1147 9.78602 20.3334 10.8624 20.3334 12.0013C20.3334 13.1402 20.1147 14.2166 19.6772 15.2305C19.2397 16.2444 18.6424 17.1298 17.8855 17.8867C17.1286 18.6437 16.2431 19.2409 15.2292 19.6784C14.2154 20.1159 13.139 20.3346 12.0001 20.3346ZM12.0001 19.0846C13.9445 19.0846 15.6112 18.3902 17.0001 17.0013C18.389 15.6124 19.0834 13.9457 19.0834 12.0013C19.0834 10.0569 18.389 8.39019 17.0001 7.0013C15.6112 5.61241 13.9445 4.91797 12.0001 4.91797C10.0556 4.91797 8.38897 5.61241 7.00008 7.0013C5.61119 8.39019 4.91675 10.0569 4.91675 12.0013C4.91675 13.9457 5.61119 15.6124 7.00008 17.0013C8.38897 18.3902 10.0556 19.0846 12.0001 19.0846Z"
fill={color ? color : "currentColor"}
/>
</svg>
);

View File

@ -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";

View File

@ -0,0 +1,24 @@
import React from "react";
import type { Props } from "./types";
export const SingleCommentCard: React.FC<Props> = ({
width = "24",
height = "24",
className,
color,
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.66663 20.3346V4.91797C3.66663 4.58464 3.79163 4.29297 4.04163 4.04297C4.29163 3.79297 4.58329 3.66797 4.91663 3.66797H19.0833C19.4166 3.66797 19.7083 3.79297 19.9583 4.04297C20.2083 4.29297 20.3333 4.58464 20.3333 4.91797V15.7513C20.3333 16.0846 20.2083 16.3763 19.9583 16.6263C19.7083 16.8763 19.4166 17.0013 19.0833 17.0013H6.99996L3.66663 20.3346ZM6.45829 15.7513H19.0833V4.91797H4.91663V17.418L6.45829 15.7513Z"
fill={color ? color : "currentColor"}
/>
</svg>
);

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const SortIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.9583 17C10.7778 17 10.6285 16.941 10.5104 16.8229C10.3924 16.7049 10.3333 16.5556 10.3333 16.375C10.3333 16.1944 10.3924 16.0451 10.5104 15.9271C10.6285 15.809 10.7778 15.75 10.9583 15.75H13.0417C13.2222 15.75 13.3715 15.809 13.4896 15.9271C13.6076 16.0451 13.6667 16.1944 13.6667 16.375C13.6667 16.5556 13.6076 16.7049 13.4896 16.8229C13.3715 16.941 13.2222 17 13.0417 17H10.9583ZM5.125 8.25C4.94444 8.25 4.79514 8.19097 4.67708 8.07292C4.55903 7.95486 4.5 7.80556 4.5 7.625C4.5 7.44444 4.55903 7.29514 4.67708 7.17708C4.79514 7.05903 4.94444 7 5.125 7H18.875C19.0556 7 19.2049 7.05903 19.3229 7.17708C19.441 7.29514 19.5 7.44444 19.5 7.625C19.5 7.80556 19.441 7.95486 19.3229 8.07292C19.2049 8.19097 19.0556 8.25 18.875 8.25H5.125ZM7.625 12.625C7.44444 12.625 7.29514 12.566 7.17708 12.4479C7.05903 12.3299 7 12.1806 7 12C7 11.8194 7.05903 11.6701 7.17708 11.5521C7.29514 11.434 7.44444 11.375 7.625 11.375H16.375C16.5556 11.375 16.7049 11.434 16.8229 11.5521C16.941 11.6701 17 11.8194 17 12C17 12.1806 16.941 12.3299 16.8229 12.4479C16.7049 12.566 16.5556 12.625 16.375 12.625H7.625Z"
fill={color ? color : "currentColor"}
/>
</svg>
);

View File

@ -0,0 +1,19 @@
import React from "react";
import type { Props } from "./types";
export const XMarkIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 12.875L7.625 17.25C7.5 17.375 7.35417 17.4375 7.1875 17.4375C7.02083 17.4375 6.875 17.375 6.75 17.25C6.625 17.125 6.5625 16.9792 6.5625 16.8125C6.5625 16.6458 6.625 16.5 6.75 16.375L11.125 12L6.75 7.625C6.625 7.5 6.5625 7.35417 6.5625 7.1875C6.5625 7.02083 6.625 6.875 6.75 6.75C6.875 6.625 7.02083 6.5625 7.1875 6.5625C7.35417 6.5625 7.5 6.625 7.625 6.75L12 11.125L16.375 6.75C16.5 6.625 16.6458 6.5625 16.8125 6.5625C16.9792 6.5625 17.125 6.625 17.25 6.75C17.375 6.875 17.4375 7.02083 17.4375 7.1875C17.4375 7.35417 17.375 7.5 17.25 7.625L12.875 12L17.25 16.375C17.375 16.5 17.4375 16.6458 17.4375 16.8125C17.4375 16.9792 17.375 17.125 17.25 17.25C17.125 17.375 16.9792 17.4375 16.8125 17.4375C16.6458 17.4375 16.5 17.375 16.375 17.25L12 12.875Z"
fill={color ? color : "currentColor"}
/>
</svg>
);

View File

@ -9,6 +9,7 @@ import { Controller, UseFormWatch } from "react-hook-form";
// 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";
@ -30,7 +31,7 @@ import {
SidebarLabelSelect,
} from "components/issues";
// ui
import { CustomDatePicker } from "components/ui";
import { CustomDatePicker, Icon } from "components/ui";
// icons
import {
LinkIcon,
@ -86,6 +87,9 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
const { user } = useUserAuth();
const { loading, handleSubscribe, handleUnsubscribe, subscribed } =
useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId);
const { memberRole } = useProjectMyMembership();
const { setToastAlert } = useToast();
@ -232,6 +236,21 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
</h4>
<div className="flex flex-wrap items-center gap-2">
{issueDetail?.created_by !== user?.id &&
!issueDetail?.assignees.includes(user?.id ?? "") &&
(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<button
type="button"
className="rounded-md flex items-center gap-2 border border-custom-primary-100 px-2 py-1 text-xs text-custom-primary-100 shadow-sm duration-300 focus:outline-none"
onClick={() => {
if (subscribed) handleUnsubscribe();
else handleSubscribe();
}}
>
<Icon iconName="notifications" />
{loading ? "Loading..." : subscribed ? "Unsubscribe" : "Subscribe"}
</button>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<button
type="button"

View File

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

View File

@ -0,0 +1,200 @@
import React from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// icons
import { Icon } from "components/ui";
// helper
import { stripHTML, replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { formatDateDistance, renderShortDateWithYearFormat } from "helpers/date-time.helper";
// type
import type { IUserNotification } from "types";
type NotificationCardProps = {
notification: IUserNotification;
markNotificationReadStatus: (notificationId: string) => Promise<void>;
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
setSelectedNotificationForSnooze: (notificationId: string) => void;
markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
};
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const {
notification,
markNotificationReadStatus,
markNotificationArchivedStatus,
setSelectedNotificationForSnooze,
markSnoozeNotification,
} = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
return (
<div
key={notification.id}
onClick={() => {
markNotificationReadStatus(notification.id);
router.push(
`/${workspaceSlug}/projects/${notification.project}/issues/${notification.data.issue.id}`
);
}}
className={`px-4 ${
notification.read_at === null ? "bg-custom-primary-70/10" : "hover:bg-custom-background-200"
}`}
>
<div className="relative group flex items-center gap-3 py-3 cursor-pointer border-b-2 border-custom-border-200">
{notification.read_at === null && (
<span className="absolute top-1/2 -left-2 -translate-y-1/2 w-1.5 h-1.5 bg-custom-primary-100 rounded-full" />
)}
<div className="flex w-full pl-2">
<div className="pl-0 p-2">
<div className="relative w-12 h-12 rounded-full">
{notification.triggered_by_details.avatar &&
notification.triggered_by_details.avatar !== "" ? (
<Image
src={notification.triggered_by_details.avatar}
alt="profile image"
layout="fill"
objectFit="cover"
className="rounded-full"
/>
) : (
<div className="w-12 h-12 bg-custom-background-100 rounded-full flex justify-center items-center">
<span className="text-custom-text-100 font-semibold text-lg">
{notification.triggered_by_details.first_name[0].toUpperCase()}
</span>
</div>
)}
</div>
</div>
<div className="w-full flex flex-col overflow-hidden">
<div>
<p>
<span className="font-semibold text-custom-text-200">
{notification.triggered_by_details.first_name}{" "}
{notification.triggered_by_details.last_name}{" "}
</span>
{notification.data.issue_activity.field !== "comment" &&
notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment"
? "commented"
: notification.data.issue_activity.field === "None"
? null
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
{notification.data.issue_activity.field !== "comment" &&
notification.data.issue_activity.field !== "None"
? "to"
: ""}
<span className="font-semibold text-custom-text-200">
{" "}
{notification.data.issue_activity.field !== "None" ? (
notification.data.issue_activity.field !== "comment" ? (
notification.data.issue_activity.field === "target_date" ? (
renderShortDateWithYearFormat(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? (
"the issue"
) : stripHTML(notification.data.issue_activity.new_value).length > 55 ? (
stripHTML(notification.data.issue_activity.new_value).slice(0, 50) + "..."
) : (
stripHTML(notification.data.issue_activity.new_value)
)
) : (
<span>
{`"`}
{notification.data.issue_activity.new_value.length > 55
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
: notification.data.issue_activity.issue_comment}
{`"`}
</span>
)
) : (
"the issue and assigned it to you."
)}
</span>
</p>
</div>
<div className="w-full flex items-center justify-between mt-3">
<p className="truncate inline max-w-lg text-custom-text-300 text-sm mr-3">
{notification.data.issue.identifier}-{notification.data.issue.sequence_id}{" "}
{notification.data.issue.name}
</p>
<p className="text-custom-text-300 text-xs">
{formatDateDistance(notification.created_at)}
</p>
</div>
</div>
</div>
<div className="absolute py-1 flex gap-x-3 right-0 top-3 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto">
{[
{
id: 1,
name: notification.read_at ? "Mark as Unread" : "Mark as Read",
icon: "chat_bubble",
onClick: () => {
markNotificationReadStatus(notification.id).then(() => {
setToastAlert({
title: notification.read_at
? "Notification marked as unread"
: "Notification marked as read",
type: "success",
});
});
},
},
{
id: 2,
name: notification.archived_at ? "Unarchive Notification" : "Archive Notification",
icon: "archive",
onClick: () => {
markNotificationArchivedStatus(notification.id).then(() => {
setToastAlert({
title: notification.archived_at
? "Notification un-archived"
: "Notification archived",
type: "success",
});
});
},
},
{
id: 3,
name: notification.snoozed_till ? "Unsnooze Notification" : "Snooze Notification",
icon: "schedule",
onClick: () => {
if (notification.snoozed_till)
markSnoozeNotification(notification.id).then(() => {
setToastAlert({ title: "Notification un-snoozed", type: "success" });
});
else setSelectedNotificationForSnooze(notification.id);
},
},
].map((item) => (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
item.onClick();
}}
key={item.id}
className="text-sm flex w-full items-center gap-x-2 hover:bg-custom-background-100 p-0.5 rounded"
>
<Icon iconName={item.icon} className="h-5 w-5 text-custom-text-300" />
</button>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,293 @@
import React, { Fragment } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
// hooks
import useTheme from "hooks/use-theme";
import { Popover, Transition } from "@headlessui/react";
// hooks
import useWorkspaceMembers from "hooks/use-workspace-members";
import useUserNotification from "hooks/use-user-notifications";
// components
import { Spinner, Icon } from "components/ui";
import { SnoozeNotificationModal, NotificationCard } from "components/notifications";
// helpers
import { getNumberCount } from "helpers/string.helper";
// type
import type { NotificationType } from "types";
export const NotificationPopover = () => {
const {
notifications,
archived,
readNotification,
selectedNotificationForSnooze,
selectedTab,
setArchived,
setReadNotification,
setSelectedNotificationForSnooze,
setSelectedTab,
setSnoozed,
snoozed,
notificationsMutate,
markNotificationArchivedStatus,
markNotificationReadStatus,
markSnoozeNotification,
notificationCount,
totalNotificationCount,
} = useUserNotification();
const router = useRouter();
const { workspaceSlug } = router.query;
const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
// theme context
const { collapsed: sidebarCollapse } = useTheme();
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_notifications,
},
];
return (
<>
<SnoozeNotificationModal
isOpen={selectedNotificationForSnooze !== null}
onClose={() => setSelectedNotificationForSnooze(null)}
onSubmit={markSnoozeNotification}
notification={
notifications?.find(
(notification) => notification.id === selectedNotificationForSnooze
) || null
}
onSuccess={() => {
notificationsMutate();
setSelectedNotificationForSnooze(null);
}}
/>
<Popover className="relative w-full">
{({ open: isActive, close: closePopover }) => (
<>
<Popover.Button
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
>
<Icon iconName="notifications" />
{sidebarCollapse ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? (
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
{getNumberCount(totalNotificationCount)}
</span>
) : null}
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 top-0 pt-5 md:w-[36rem] w-[20rem] h-[27rem] border border-custom-background-90 shadow-lg rounded">
<div className="flex justify-between items-center md:px-6 px-2">
<h2 className="text-custom-sidebar-text-100 text-lg font-semibold mb-2">
Notifications
</h2>
<div className="flex gap-x-2 justify-center items-center">
<button
type="button"
onClick={(e) => {
notificationsMutate();
const target = e.target as HTMLButtonElement;
target?.classList.add("animate-spin");
setTimeout(() => {
target?.classList.remove("animate-spin");
}, 1000);
}}
>
<Icon iconName="refresh" className="h-6 w-6 text-custom-text-300" />
</button>
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification((prev) => !prev);
}}
>
<Icon iconName="filter_list" className="h-6 w-6 text-custom-text-300" />
</button>
<button
type="button"
onClick={() => {
setArchived(false);
setReadNotification(false);
setSnoozed((prev) => !prev);
}}
>
<Icon iconName="schedule" className="h-6 w-6 text-custom-text-300" />
</button>
<button
type="button"
onClick={() => {
setSnoozed(false);
setReadNotification(false);
setArchived((prev) => !prev);
}}
>
<Icon iconName="archive" className="h-6 w-6 text-custom-text-300" />
</button>
<button type="button" onClick={() => closePopover()}>
<Icon iconName="close" className="h-6 w-6 text-custom-text-300" />
</button>
</div>
</div>
<div className="mt-5 flex flex-col items-center">
{snoozed || archived || readNotification ? (
<div className="w-full mb-3">
<div className="flex flex-col flex-1 px-2 md:px-6 overflow-y-auto">
<button
type="button"
onClick={() => {
setSnoozed(false);
setArchived(false);
setReadNotification(false);
}}
>
<h4 className="text-custom-text-300 text-center flex items-center">
<Icon iconName="arrow_back" className="h-5 w-5 text-custom-text-300" />
<span className="ml-2 font-semibold">
{snoozed
? "Snoozed Notifications"
: readNotification
? "Read Notifications"
: "Archived Notifications"}
</span>
</h4>
</button>
</div>
</div>
) : (
<div className="border-b border-custom-border-300 md:px-6 px-2 w-full">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
{notificationTabs.map((tab) =>
tab.value === "created" ? (
isMember || isOwner ? (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-500 hover:border-custom-border-300 hover:text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span className="ml-3 bg-custom-background-1000/5 rounded-full text-custom-text-100 text-xs px-1.5">
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
) : null
) : (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-500 hover:border-custom-border-300 hover:text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span className="ml-3 bg-custom-background-1000/5 rounded-full text-custom-text-100 text-xs px-1.5">
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
)
)}
</nav>
</div>
)}
</div>
<div className="w-full flex-1 overflow-y-auto">
{notifications ? (
notifications.filter(
(notification) => notification.data.issue_activity.field !== "None"
).length > 0 ? (
notifications.map((notification) => (
<NotificationCard
key={notification.id}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
))
) : (
<div className="flex flex-col w-full h-full justify-center items-center">
<Image
src="/empty-state/empty-notification.svg"
alt="Empty"
width={200}
height={200}
layout="fixed"
/>
<h4 className="text-custom-text-300 text-lg font-semibold">
You{"'"}re updated with all the notifications
</h4>
<p className="text-custom-text-300 text-sm mt-2">
You have read all the notifications.
</p>
</div>
)
) : (
<div className="flex w-full h-full justify-center items-center">
<Spinner />
</div>
)}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</>
);
};

View File

@ -0,0 +1,325 @@
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";
// date helper
import { getDatesAfterCurrentDate, getTimestampAfterCurrentTime } from "helpers/date-time.helper";
// hooks
import useToast from "hooks/use-toast";
// components
import { PrimaryButton, SecondaryButton, Icon } from "components/ui";
// types
import type { IUserNotification } from "types";
type SnoozeModalProps = {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
notification: IUserNotification | null;
onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
};
const dates = getDatesAfterCurrentDate();
const timeStamps = getTimestampAfterCurrentTime();
const defaultValues = {
time: null,
date: null,
};
export const SnoozeNotificationModal: React.FC<SnoozeModalProps> = (props) => {
const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
formState: { isSubmitting },
reset,
handleSubmit,
control,
} = useForm<any>({
defaultValues,
});
const onSubmit = async (formData: any) => {
if (!workspaceSlug || !notification) return;
const dateTime = new Date(
`${formData.date.toLocaleDateString()} ${formData.time.toLocaleTimeString()}`
);
await handleSubmitSnooze(notification.id, 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 (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex items-center justify-center min-h-full p-4 text-center">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-100 bg-custom-background-80 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex justify-between items-center">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Customize Snooze Time
</Dialog.Title>
<div>
<button type="button">
<Icon iconName="close" className="w-5 h-5 text-custom-text-100" />
</button>
</div>
</div>
<div className="mt-5 flex gap-3">
<div className="flex-1">
<Controller
control={control}
name="time"
rules={{ required: "Please select a time" }}
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative mt-2">
<Listbox.Button className="relative w-full cursor-default rounded-md border border-custom-border-100 bg-custom-background-100 py-1.5 pl-3 pr-10 text-left text-custom-text-100 shadow-sm focus:outline-none sm:text-sm sm:leading-6">
<span className="flex items-center">
<span className="ml-3 block truncate">
{value
? new Date(value)?.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "Select Time"}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
<Icon
iconName="expand_more"
className="h-5 w-5 text-custom-text-100"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-custom-background-100 py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{timeStamps.map((time, index) => (
<Listbox.Option
key={`${time.label}-${index}`}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-3 pr-9 ${
active
? "bg-custom-primary-100/80 text-custom-text-100"
: "text-custom-text-700"
}`
}
value={time.value}
>
{({ selected, active }) => (
<>
<div className="flex items-center">
<span
className={`ml-3 block truncate ${
selected ? "font-semibold" : "font-normal"
}`}
>
{time.label}
</span>
</div>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active
? "text-custom-text-100"
: "text-custom-primary-100"
}`}
>
<Icon
iconName="done"
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
<div className="flex-1">
<Controller
name="date"
control={control}
rules={{ required: "Please select a date" }}
render={({ field: { value, onChange } }) => (
<Listbox value={value} onChange={onChange}>
{({ open }) => (
<>
<div className="relative mt-2">
<Listbox.Button className="relative w-full cursor-default rounded-md border border-custom-border-100 bg-custom-background-100 py-1.5 pl-3 pr-10 text-left text-custom-text-100 shadow-sm focus:outline-none sm:text-sm sm:leading-6">
<span className="flex items-center">
<span className="ml-3 block truncate">
{value
? new Date(value)?.toLocaleDateString([], {
day: "numeric",
month: "long",
year: "numeric",
})
: "Select Date"}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
<Icon
iconName="expand_more"
className="h-5 w-5 text-custom-text-100"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-custom-background-100 py-1 text-base shadow-lg focus:outline-none sm:text-sm">
{dates.map((date, index) => (
<Listbox.Option
key={`${date.label}-${index}`}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-3 pr-9 ${
active
? "bg-custom-primary-100/80 text-custom-text-100"
: "text-custom-text-700"
}`
}
value={date.value}
>
{({ selected, active }) => (
<>
<div className="flex items-center">
<span
className={`ml-3 block truncate ${
selected ? "font-semibold" : "font-normal"
}`}
>
{date.label}
</span>
</div>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active
? "text-custom-text-100"
: "text-custom-primary-100"
}`}
>
<Icon
iconName="done"
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="w-full flex items-center gap-2 justify-end">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</PrimaryButton>
</div>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

2991
apps/app/components/ui/icon-name-type.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,36 @@
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";
import { NotificationPopover } from "components/notifications";
const workspaceLinks = (workspaceSlug: string) => [
{
icon: "grid_view",
name: "Dashboard",
href: `/${workspaceSlug}`,
},
{
icon: "bar_chart",
name: "Analytics",
href: `/${workspaceSlug}/analytics`,
},
{
icon: "work",
name: "Projects",
href: `/${workspaceSlug}/projects`,
},
{
icon: "task_alt",
name: "My Issues",
href: `/${workspaceSlug}/me/my-issues`,
},
];
// components
import { Icon, Tooltip } from "components/ui";
@ -15,34 +41,6 @@ export const WorkspaceSidebarMenu = () => {
// theme context
const { collapsed: sidebarCollapse } = useTheme();
const workspaceLinks = (workspaceSlug: string) => [
{
icon: "grid_view",
name: "Dashboard",
href: `/${workspaceSlug}`,
},
{
icon: "bar_chart",
name: "Analytics",
href: `/${workspaceSlug}/analytics`,
},
{
icon: "work",
name: "Projects",
href: `/${workspaceSlug}/projects`,
},
{
icon: "task_alt",
name: "My Issues",
href: `/${workspaceSlug}/me/my-issues`,
},
{
icon: "settings",
name: "Settings",
href: `/${workspaceSlug}/settings`,
},
];
return (
<div className="w-full cursor-pointer space-y-2 px-4 mt-5">
{workspaceLinks(workspaceSlug as string).map((link, index) => {
@ -75,6 +73,8 @@ export const WorkspaceSidebarMenu = () => {
</Link>
);
})}
<NotificationPopover />
</div>
);
};

View File

@ -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;
@ -223,3 +223,24 @@ export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnaly
`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()}`;

View File

@ -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
@ -247,3 +276,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(Math.round(current.getTime() / (30 * 60 * 1000)) * 30 * 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";

View File

@ -118,3 +118,36 @@ 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 = "<p>Some text</p>";
* 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 || "";
};
/**
* @description: This function return number count in string if number is more than 100 then it will return 99+
* @param {number} number
* @return {string}
* @example:
* const number = 100;
* const text = getNumberCount(number);
* console.log(text); // 99+
*/
export const getNumberCount = (number: number): string => {
if (number > 99) {
return "99+";
}
return number.toString();
};

View File

@ -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;

View File

@ -0,0 +1,187 @@
import { useState } from "react";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// services
import userNotificationServices from "services/notifications.service";
// fetch-keys
import { UNREAD_NOTIFICATIONS_COUNT, 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<boolean>(false);
const [archived, setArchived] = useState<boolean>(false);
const [readNotification, setReadNotification] = useState<boolean>(false);
const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState<string | null>(
null
);
const [selectedTab, setSelectedTab] = useState<NotificationType>("assigned");
const { data: notifications, mutate: notificationsMutate } = useSWR(
workspaceSlug
? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), {
type: selectedTab,
snoozed,
archived,
read: selectedTab === null ? !readNotification : undefined,
})
: null,
workspaceSlug
? () =>
userNotificationServices.getUserNotifications(workspaceSlug.toString(), {
type: selectedTab,
snoozed,
archived,
read: selectedTab === null ? !readNotification : undefined,
})
: null
);
const { data: notificationCount, mutate: mutateNotificationCount } = useSWR(
workspaceSlug ? UNREAD_NOTIFICATIONS_COUNT(workspaceSlug.toString()) : null,
() =>
workspaceSlug
? userNotificationServices.getUnreadNotificationsCount(workspaceSlug.toString())
: 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;
})
);
mutateNotificationCount();
})
.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;
})
);
mutateNotificationCount();
})
.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");
});
}
};
const markSnoozeNotification = async (notificationId: string, dateTime?: Date) => {
if (!workspaceSlug) return;
const isSnoozed =
notifications?.find((notification) => notification.id === notificationId)?.snoozed_till !==
null;
if (isSnoozed) {
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: null,
})
.then(() => {
notificationsMutate();
});
} else
await userNotificationServices
.patchUserNotification(workspaceSlug.toString(), notificationId, {
snoozed_till: dateTime,
})
.then(() => {
notificationsMutate(
(prevData) => prevData?.filter((prev) => prev.id !== notificationId) || []
);
});
};
return {
notifications,
notificationsMutate,
markNotificationReadStatus,
markNotificationArchivedStatus,
markSnoozeNotification,
snoozed,
setSnoozed,
archived,
setArchived,
readNotification,
setReadNotification,
selectedNotificationForSnooze,
setSelectedNotificationForSnooze,
selectedTab,
setSelectedTab,
totalNotificationCount: notificationCount
? notificationCount.created_issues +
notificationCount.watching_notifications +
notificationCount.my_issues
: null,
notificationCount,
mutateNotificationCount,
};
};
export default useUserNotification;

View File

@ -0,0 +1,38 @@
<svg
width="225"
height="114"
viewBox="0 0 225 114"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M75.1109 42.5002C75.1109 54.7286 65.4875 64 65.4875 64C65.4875 64 67.1611 56.9998 67.1611 55.5C67.1611 50.5 65.4875 49 65.4875 49C65.4875 49 45.5822 54.9992 37.0358 56.0002L36.7439 56.0344C29.1379 56.9259 22.4594 57.7087 15.2784 54.5C10.6109 52.4144 1.02044 45.4605 0.110879 35C-0.889068 23.5 5.05642 13.9998 10.611 10C17.2548 5.21589 25.5629 0.0359205 37.0358 0.000267114C48.6692 -0.0358854 57.7579 3.59429 66.3244 13.0002C73.8596 21.2737 75.1109 30.2717 75.1109 42.5002Z"
fill="#F1F3F5"
/>
<path
d="M136.111 90.5002C136.111 102.729 147.611 112 147.611 112C147.611 112 145.611 105 145.611 103.5C145.611 98.5 147.611 97 147.611 97C147.611 97 171.398 102.999 181.611 104L181.96 104.034C191.049 104.926 199.03 105.709 207.611 102.5C214.825 99.8024 217.975 96.998 222.111 90.5002C227.111 82.6445 224.047 69.6734 216.111 61.0002C206.856 50.8856 195.321 48.0359 181.611 48.0003C167.709 47.9641 156.848 51.5943 146.611 61.0002C137.606 69.2737 136.111 78.2717 136.111 90.5002Z"
fill="#F1F3F5"
/>
<path
d="M77.5378 88.7096C76.8029 88.7096 76.1869 88.4596 75.6898 87.9596C75.1927 87.4595 74.9441 86.8399 74.9441 86.1008C74.9441 85.3615 75.1927 84.747 75.6898 84.257C76.1869 83.7671 76.8029 83.5221 77.5378 83.5221H82.2066V57.0659C82.2066 52.3395 83.6332 48.0598 86.4863 44.2268C89.3394 40.3938 93.1003 37.9874 97.7691 37.0076V34.5003C97.7691 33.1746 98.2378 32.0794 99.1753 31.2148C100.113 30.3503 101.251 29.918 102.59 29.918C103.93 29.918 105.075 30.3503 106.026 31.2148C106.977 32.0794 107.452 33.1746 107.452 34.5003V37.0076C112.121 37.9874 115.897 40.3938 118.778 44.2268C121.66 48.0598 123.101 52.3395 123.101 57.0659V83.5221H127.684C128.419 83.5221 129.035 83.7721 129.532 84.2722C130.029 84.7722 130.277 85.3919 130.277 86.131C130.277 86.8702 130.029 87.4848 129.532 87.9747C129.035 88.4647 128.419 88.7096 127.684 88.7096H77.5378ZM102.611 99.0846C100.766 99.0846 99.1524 98.4074 97.7691 97.0529C96.3858 95.6984 95.6941 94.0701 95.6941 92.168H109.527C109.527 94.0701 108.85 95.6984 107.496 97.0529C106.141 98.4074 104.513 99.0846 102.611 99.0846ZM87.3941 83.5221H117.914V57.0659C117.914 52.743 116.458 49.0973 113.548 46.1289C110.637 43.1605 107.035 41.6763 102.74 41.6763C98.4463 41.6763 94.8151 43.1605 91.8467 46.1289C88.8783 49.0973 87.3941 52.743 87.3941 57.0659V83.5221Z"
fill="#ACB5BD"
/>
<circle cx="119.111" cy="40" r="10" fill="white" />
<circle cx="119.111" cy="40.0004" r="6.36364" fill="#3F76FF" />
<path
d="M118.048 42.4383L115.715 40.105C115.715 40.105 115.654 39.7966 115.777 39.6885C115.891 39.5892 116.163 39.6571 116.163 39.6571L118.048 41.5425L122.048 37.5425C122.048 37.5425 122.336 37.492 122.444 37.6051C122.549 37.7149 122.496 37.9904 122.496 37.9904L118.048 42.4383Z"
fill="white"
/>
<circle cx="57.1108" cy="85" r="4" fill="#CED4DA" />
<circle cx="83.1108" cy="112" r="2" fill="#E9ECEF" />
<circle cx="159.111" cy="17" r="6" fill="#DEE2E6" />
<circle cx="115.5" cy="17.5" r="3.5" fill="#E9ECEF" />
<path
d="M173.5 78.5C173.083 78.5 172.729 78.3542 172.437 78.0625C172.146 77.7708 172 77.4167 172 77C172 76.5833 172.146 76.2292 172.437 75.9375C172.729 75.6458 173.083 75.5 173.5 75.5C173.917 75.5 174.271 75.6458 174.563 75.9375C174.854 76.2292 175 76.5833 175 77C175 77.4167 174.854 77.7708 174.563 78.0625C174.271 78.3542 173.917 78.5 173.5 78.5ZM182 78.5C181.583 78.5 181.229 78.3542 180.938 78.0625C180.646 77.7708 180.5 77.4167 180.5 77C180.5 76.5833 180.646 76.2292 180.938 75.9375C181.229 75.6458 181.583 75.5 182 75.5C182.417 75.5 182.771 75.6458 183.062 75.9375C183.354 76.2292 183.5 76.5833 183.5 77C183.5 77.4167 183.354 77.7708 183.062 78.0625C182.771 78.3542 182.417 78.5 182 78.5ZM190.5 78.5C190.083 78.5 189.729 78.3542 189.438 78.0625C189.146 77.7708 189 77.4167 189 77C189 76.5833 189.146 76.2292 189.438 75.9375C189.729 75.6458 190.083 75.5 190.5 75.5C190.917 75.5 191.271 75.6458 191.562 75.9375C191.854 76.2292 192 76.5833 192 77C192 77.4167 191.854 77.7708 191.562 78.0625C191.271 78.3542 190.917 78.5 190.5 78.5Z"
fill="#858E96"
/>
<path
d="M26.5 31.5C26.0833 31.5 25.7292 31.3542 25.4375 31.0625C25.1458 30.7708 25 30.4167 25 30C25 29.5833 25.1458 29.2292 25.4375 28.9375C25.7292 28.6458 26.0833 28.5 26.5 28.5C26.9167 28.5 27.2708 28.6458 27.5625 28.9375C27.8542 29.2292 28 29.5833 28 30C28 30.4167 27.8542 30.7708 27.5625 31.0625C27.2708 31.3542 26.9167 31.5 26.5 31.5ZM35 31.5C34.5833 31.5 34.2292 31.3542 33.9375 31.0625C33.6458 30.7708 33.5 30.4167 33.5 30C33.5 29.5833 33.6458 29.2292 33.9375 28.9375C34.2292 28.6458 34.5833 28.5 35 28.5C35.4167 28.5 35.7708 28.6458 36.0625 28.9375C36.3542 29.2292 36.5 29.5833 36.5 30C36.5 30.4167 36.3542 30.7708 36.0625 31.0625C35.7708 31.3542 35.4167 31.5 35 31.5ZM43.5 31.5C43.0833 31.5 42.7292 31.3542 42.4375 31.0625C42.1458 30.7708 42 30.4167 42 30C42 29.5833 42.1458 29.2292 42.4375 28.9375C42.7292 28.6458 43.0833 28.5 43.5 28.5C43.9167 28.5 44.2708 28.6458 44.5625 28.9375C44.8542 29.2292 45 29.5833 45 30C45 30.4167 44.8542 30.7708 44.5625 31.0625C44.2708 31.3542 43.9167 31.5 43.5 31.5Z"
fill="#858E96"
/>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,174 @@
// 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<IUserNotification[]> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserNotificationDetailById(
workspaceSlug: string,
notificationId: string
): Promise<IUserNotification> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsRead(
workspaceSlug: string,
notificationId: string
): Promise<IUserNotification> {
return this.post(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsUnread(
workspaceSlug: string,
notificationId: string
): Promise<IUserNotification> {
return this.delete(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/read/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsArchived(
workspaceSlug: string,
notificationId: string
): Promise<IUserNotification> {
return this.post(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async markUserNotificationAsUnarchived(
workspaceSlug: string,
notificationId: string
): Promise<IUserNotification> {
return this.delete(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/archive/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async patchUserNotification(
workspaceSlug: string,
notificationId: string,
data: Partial<IUserNotification>
): Promise<IUserNotification> {
return this.patch(
`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteUserNotification(workspaceSlug: string, notificationId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/users/notifications/${notificationId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async subscribeToIssueNotifications(
workspaceSlug: string,
projectId: string,
issueId: string,
data: {
subscriber: string;
}
): Promise<any> {
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<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/subscribe/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUnreadNotificationsCount(workspaceSlug: string): Promise<{
created_issues: number;
my_issues: number;
watching_notifications: number;
}> {
return this.get(`/api/workspaces/${workspaceSlug}/users/notifications/unread/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
const userNotificationServices = new UserNotificationsServices();
export default userNotificationServices;

View File

@ -15,6 +15,7 @@ export * from "./importer";
export * from "./inbox";
export * from "./analytics";
export * from "./calendar";
export * from "./notifications";
export * from "./waitlist";
export type NestedKeyOf<ObjectType extends object> = {

56
apps/app/types/notifications.d.ts vendored Normal file
View File

@ -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" | null;
export interface INotificationParams {
snoozed?: boolean;
type?: NotificationType;
archived?: boolean;
read?: boolean;
}