Skip to content

Commit

Permalink
feat: user issue notifications (#1523)
Browse files Browse the repository at this point in the history
* 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 b22e062.

* 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 <[email protected]>
Co-authored-by: pablohashescobar <[email protected]>
  • Loading branch information
3 people authored Jul 18, 2023
1 parent 6e9f397 commit 16a7bd3
Show file tree
Hide file tree
Showing 23 changed files with 4,665 additions and 32 deletions.
19 changes: 19 additions & 0 deletions apps/app/components/icons/archive-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
24 changes: 24 additions & 0 deletions apps/app/components/icons/bell-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
19 changes: 19 additions & 0 deletions apps/app/components/icons/clock-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
6 changes: 6 additions & 0 deletions apps/app/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
24 changes: 24 additions & 0 deletions apps/app/components/icons/single-comment-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
19 changes: 19 additions & 0 deletions apps/app/components/icons/sort-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
19 changes: 19 additions & 0 deletions apps/app/components/icons/x-mark-icon.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
21 changes: 20 additions & 1 deletion apps/app/components/issues/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +31,7 @@ import {
SidebarLabelSelect,
} from "components/issues";
// ui
import { CustomDatePicker } from "components/ui";
import { CustomDatePicker, Icon } from "components/ui";
// icons
import {
LinkIcon,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions apps/app/components/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./notification-card";
export * from "./notification-popover";
export * from "./select-snooze-till-modal";
200 changes: 200 additions & 0 deletions apps/app/components/notifications/notification-card.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Loading

0 comments on commit 16a7bd3

Please sign in to comment.