diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 7a372c5d8..e709bbca3 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useEffect, useRef } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; -import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react"; +import { ArchiveRestore, Clock, MessageSquare, MoreVertical, User2 } from "lucide-react"; import Link from "next/link"; // hooks import useToast from "hooks/use-toast"; @@ -14,6 +14,7 @@ import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; // type import type { IUserNotification } from "@plane/types"; +import { Menu } from "@headlessui/react"; type NotificationCardProps = { notification: IUserNotification; @@ -40,8 +41,73 @@ export const NotificationCard: React.FC = (props) => { const router = useRouter(); const { workspaceSlug } = router.query; - + // states + const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false); + // toast alert const { setToastAlert } = useToast(); + // refs + const snoozeRef = useRef(null); + + const moreOptions = [ + { + id: 1, + name: notification.read_at ? "Mark as unread" : "Mark as read", + icon: , + onClick: () => { + markNotificationReadStatusToggle(notification.id).then(() => { + setToastAlert({ + title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", + type: "success", + }); + }); + }, + }, + { + id: 2, + name: notification.archived_at ? "Unarchive" : "Archive", + icon: notification.archived_at ? ( + + ) : ( + + ), + onClick: () => { + markNotificationArchivedStatus(notification.id).then(() => { + setToastAlert({ + title: notification.archived_at ? "Notification un-archived" : "Notification archived", + type: "success", + }); + }); + }, + }, + ]; + + const snoozeOptionOnClick = (date: Date | null) => { + if (!date) { + setSelectedNotificationForSnooze(notification.id); + return; + } + markSnoozeNotification(notification.id, date).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderFormattedDate(date)}`, + type: "success", + }); + }); + }; + + // close snooze options on outside click + useEffect(() => { + const handleClickOutside = (event: any) => { + if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) { + setshowSnoozeOptions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside, true); + document.addEventListener("touchend", handleClickOutside, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside, true); + document.removeEventListener("touchend", handleClickOutside, true); + }; + }, []); if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null; @@ -87,57 +153,136 @@ export const NotificationCard: React.FC = (props) => { )}
- {!notification.message ? ( -
- - {notification.triggered_by_details.is_bot - ? notification.triggered_by_details.first_name - : notification.triggered_by_details.display_name}{" "} - - {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" - : ""} - - {" "} - {notification.data.issue_activity.field !== "None" ? ( - notification.data.issue_activity.field !== "comment" ? ( - notification.data.issue_activity.field === "target_date" ? ( - renderFormattedDate(notification.data.issue_activity.new_value) - ) : notification.data.issue_activity.field === "attachment" ? ( - "the issue" - ) : notification.data.issue_activity.field === "description" ? ( - stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) +
+ {!notification.message ? ( +
+ + {notification.triggered_by_details.is_bot + ? notification.triggered_by_details.first_name + : notification.triggered_by_details.display_name}{" "} + + {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" + : ""} + + {" "} + {notification.data.issue_activity.field !== "None" ? ( + notification.data.issue_activity.field !== "comment" ? ( + notification.data.issue_activity.field === "target_date" ? ( + renderFormattedDate(notification.data.issue_activity.new_value) + ) : notification.data.issue_activity.field === "attachment" ? ( + "the issue" + ) : notification.data.issue_activity.field === "description" ? ( + stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) + ) : ( + notification.data.issue_activity.new_value + ) ) : ( - notification.data.issue_activity.new_value + + {`"`} + {notification.data.issue_activity.new_value.length > 55 + ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." + : notification.data.issue_activity.issue_comment} + {`"`} + ) ) : ( - - {`"`} - {notification.data.issue_activity.new_value.length > 55 - ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." - : notification.data.issue_activity.issue_comment} - {`"`} - - ) - ) : ( - "the issue and assigned it to you." + "the issue and assigned it to you." + )} + +
+ ) : ( +
+ {notification.message} +
+ )} +
+ + {({ open }) => ( + <> + + + + {open && ( + +
+ {moreOptions.map((item) => ( + + {({ close }) => ( + + )} + + ))} + +
{ + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(true); + }} + className="flex gap-x-2 items-center p-1.5" + > + + Snooze +
+
+
+
+ )} + )} - +
+ {showSnoozeOptions && ( +
+ {snoozeOptions.map((item) => ( +

{ + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(false); + snoozeOptionOnClick(item.value); + }} + > + {item.label} +

+ ))} +
+ )}
- ) : ( -
- {notification.message} -
- )} +
-

+

{truncateText( `${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`, 50 @@ -152,43 +297,12 @@ export const NotificationCard: React.FC = (props) => {

) : ( -

{calculateTimeAgo(notification.created_at)}

+

{calculateTimeAgo(notification.created_at)}

)}
-
- {[ - { - id: 1, - name: notification.read_at ? "Mark as unread" : "Mark as read", - icon: , - onClick: () => { - markNotificationReadStatusToggle(notification.id).then(() => { - setToastAlert({ - title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", - type: "success", - }); - }); - }, - }, - { - id: 2, - name: notification.archived_at ? "Unarchive" : "Archive", - icon: notification.archived_at ? ( - - ) : ( - - ), - onClick: () => { - markNotificationArchivedStatus(notification.id).then(() => { - setToastAlert({ - title: notification.archived_at ? "Notification un-archived" : "Notification archived", - type: "success", - }); - }); - }, - }, - ].map((item) => ( +
+ {moreOptions.map((item) => (
- - - +
+ + + +
diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index 4b55ea4cb..47fdae6ef 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; // hooks import { useApplication } from "hooks/store"; import useUserNotification from "hooks/use-user-notifications"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { EmptyState } from "components/common"; import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications"; @@ -15,8 +16,12 @@ import emptyNotification from "public/empty-state/notification.svg"; import { getNumberCount } from "helpers/string.helper"; export const NotificationPopover = observer(() => { + // states + const [isActive, setIsActive] = React.useState(false); // store hooks const { theme: themeStore } = useApplication(); + // refs + const notificationPopoverRef = React.useRef(null); const { notifications, @@ -44,8 +49,11 @@ export const NotificationPopover = observer(() => { setFetchNotifications, markAllNotificationsAsRead, } = useUserNotification(); - const isSidebarCollapsed = themeStore.sidebarCollapsed; + useOutsideClickDetector(notificationPopoverRef, () => { + // if snooze modal is open, then don't close the popover + if (selectedNotificationForSnooze === null) setIsActive(false); + }); return ( <> @@ -54,141 +62,142 @@ export const NotificationPopover = observer(() => { onClose={() => setSelectedNotificationForSnooze(null)} onSubmit={markSnoozeNotification} notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null} - onSuccess={() => { - setSelectedNotificationForSnooze(null); - }} + onSuccess={() => setSelectedNotificationForSnooze(null)} /> - - {({ open: isActive, close: closePopover }) => { - if (isActive) setFetchNotifications(true); + + <> + + + + + + setIsActive(false)} + isRefreshing={isRefreshing} + snoozed={snoozed} + archived={archived} + readNotification={readNotification} + selectedTab={selectedTab} + setSnoozed={setSnoozed} + setArchived={setArchived} + setReadNotification={setReadNotification} + setSelectedTab={setSelectedTab} + markAllNotificationsAsRead={markAllNotificationsAsRead} + /> - return ( - <> - - - - {isSidebarCollapsed ? null : Notifications} - {totalNotificationCount && totalNotificationCount > 0 ? ( - isSidebarCollapsed ? ( - - ) : ( - - {getNumberCount(totalNotificationCount)} - - ) - ) : null} - - - - - - - {notifications ? ( - notifications.length > 0 ? ( -
-
- {notifications.map((notification) => ( - - ))} -
- {isLoadingMore && ( -
-
- - Loading... -
-

Loading notifications

-
- )} - {hasMore && !isLoadingMore && ( - - )} -
- ) : ( -
- 0 ? ( +
+
+ {notifications.map((notification) => ( + setIsActive(false)} + notification={notification} + markNotificationArchivedStatus={markNotificationArchivedStatus} + markNotificationReadStatus={markNotificationAsRead} + markNotificationReadStatusToggle={markNotificationReadStatus} + setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} + markSnoozeNotification={markSnoozeNotification} /> + ))} +
+ {isLoadingMore && ( +
+
+ + Loading... +
+

Loading notifications

- ) - ) : ( - - - - - - - - )} - - - - ); - }} + )} + {hasMore && !isLoadingMore && ( + + )} +
+ ) : ( +
+ +
+ ) + ) : ( + + + + + + + + )} + + + ); diff --git a/web/components/notifications/select-snooze-till-modal.tsx b/web/components/notifications/select-snooze-till-modal.tsx index ab3497bb8..2ad4b0ef2 100644 --- a/web/components/notifications/select-snooze-till-modal.tsx +++ b/web/components/notifications/select-snooze-till-modal.tsx @@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC = (props) => { }; const handleClose = () => { - onClose(); + // This is a workaround to fix the issue of the Notification popover modal close on closing this modal + const closeTimeout = setTimeout(() => { + onClose(); + clearTimeout(closeTimeout); + }, 50); + const timeout = setTimeout(() => { reset({ ...defaultValues }); clearTimeout(timeout); @@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
@@ -156,8 +161,8 @@ export const SnoozeNotificationModal: FC = (props) => {
-
-
+
+
Pick a date