Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "react-tooltip/dist/react-tooltip.css";

import AuthenticationProvider from "@/components/AuthenticationProvider";
import NotificationHandler from "@/components/NotificationHandler";
import FloatingNotification from "@/components/dashboard/FloatingNotification";
import { Toaster } from "react-hot-toast";
import NavbarLayout from "@/components/NavBarLayout";

Expand All @@ -28,7 +29,7 @@ export default function RootLayout({
containerClassName="general-toaster"
containerStyle={{
top: 20,
right: 80
right: 80,
}}
toastOptions={{
duration: 4000,
Expand All @@ -49,6 +50,7 @@ export default function RootLayout({
},
}}
/>
<FloatingNotification />
</NotificationHandler>
</AuthenticationProvider>
</body>
Expand Down
83 changes: 78 additions & 5 deletions src/components/NotificationHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from "react";
import FloatingNotification from "./dashboard/FloatingNotification";
import { useUser } from "./context/UserContext";
import { Notification } from "@prisma/client";
import toast, { Toast } from "react-hot-toast";
Expand Down Expand Up @@ -43,9 +43,11 @@ function getRealtimeClient() {
const NotificationContext = createContext<{
notifications: UnifiedNotification[];
refreshNotifications: () => Promise<void>;
dismissNotification: (id: string | number) => Promise<void>;
}>({
notifications: [],
refreshNotifications: async () => {},
dismissNotification: async () => {},
});

export function useNotifications() {
Expand All @@ -63,13 +65,81 @@ export default function NotificationHandler({
const [client, setClient] = useState<Ably.Realtime | null>(null);
const [notifications, setNotifications] = useState<UnifiedNotification[]>([]);

// Ensure that mirroring doesn't overwrite sessionStorage before loading
const isHydrated = useRef(false);

useEffect(() => {
const realtime = getRealtimeClient();
if (!realtime) return;
setClient(realtime);
if (realtime.connection.state === "initialized") realtime.connect();
}, []);

useEffect(() => {
try {
const stored = sessionStorage.getItem("chat_notifications");
if (stored) {
const parsed: UnifiedNotification[] = JSON.parse(stored);
// Re-instantiate dateCreated using new Date() as JSON serialization turns dates into strings
const hydrated = parsed.map((n) => ({
...n,
dateCreated: new Date(n.dateCreated),
}));
setNotifications((prev) => [...hydrated, ...prev]);
}
} catch (error) {
console.error("Failed to hydrate chat notifications:", error);
} finally {
isHydrated.current = true;
}
}, []);

useEffect(() => {
if (!isHydrated.current) return;
const chatNotifications = notifications.filter((n) => n.isChat);
try {
sessionStorage.setItem(
"chat_notifications",
JSON.stringify(chatNotifications)
);
} catch (e) {
console.error(
"Failed to mirror chat notifications to sessionStorage:",
e
);
}
}, [notifications]);

const dismissNotification = useCallback(
async (id: string | number) => {
const notification = notifications.find((n) => n.id === id);
if (!notification?.isChat && Number(id) > 0) {
try {
await apiClient.delete(`/api/notifications/${id}`);
} catch (error) {
console.error(`Failed to delete notification ${id}: ${error}`);
}
}
setNotifications((prev) => prev.filter((n) => n.id !== id));
},
[apiClient, notifications]
);
Comment on lines +113 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing isChat guard before DELETE call

The dismissNotification callback checks Number(id) > 0 before making the DELETE API call, but this is insufficient for chat notifications.

Chat notification IDs fall back to Date.now() when event.message?.id is undefined (line 269). A timestamp like 1709913600000 evaluates to Number(id) > 0 === true, so the callback fires an unnecessary DELETE /api/notifications/<timestamp> request for a notification that only exists locally (in sessionStorage), not in the database.

Since every notification has an explicit isChat flag, the fix is straightforward — look up the notification from state and preserve the original guard:

const dismissNotification = useCallback(
  async (id: string | number) => {
    const notification = notifications.find((n) => n.id === id);
    if (!notification?.isChat && Number(id) > 0) {
      try {
        await apiClient.delete(`/api/notifications/${id}`);
      } catch (error) {
        console.error(`Failed to delete notification ${id}: ${error}`);
      }
    }
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  },
  [apiClient, notifications]
);


useEffect(() => {
if (!user) {
sessionStorage.removeItem("chat_notifications");
}
}, [user]);

useEffect(() => {
const currentUrl = `${pathname}${
searchParams.toString() ? `?${searchParams.toString()}` : ""
}`;
setNotifications((prev) =>
prev.filter((n) => !n.isChat || n.action !== currentUrl)
);
}, [pathname, searchParams]);

const refreshNotifications = useCallback(async () => {
if (!user?.id) return;
try {
Expand All @@ -85,7 +155,11 @@ export default function NotificationHandler({
dateCreated: new Date(n.dateCreated),
isChat: false,
}));
setNotifications(mapped);

setNotifications((prev) => {
const chatNotifs = prev.filter((n) => n.isChat);
return [...chatNotifs, ...mapped];
});
} catch (error) {
console.error(`Failed to fetch notifications: ${error}`);
}
Expand Down Expand Up @@ -261,13 +335,12 @@ export default function NotificationHandler({
]);

const value = useMemo(
() => ({ notifications, refreshNotifications }),
[notifications, refreshNotifications]
() => ({ notifications, refreshNotifications, dismissNotification }),
[notifications, refreshNotifications, dismissNotification]
);

return (
<NotificationContext.Provider value={value}>
<FloatingNotification notifications={notifications} />
{children}
</NotificationContext.Provider>
);
Expand Down
11 changes: 3 additions & 8 deletions src/components/dashboard/FloatingNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@
import { useState } from "react";
import { Bell } from "@phosphor-icons/react";
import NotificationsPanel from "./NotificationsPanel";
import { UnifiedNotification } from "../NotificationHandler";
import { useNotifications } from "../NotificationHandler";

interface FloatingNotificationProps {
notifications: UnifiedNotification[];
}

export default function FloatingNotification({
notifications,
}: FloatingNotificationProps) {
export default function FloatingNotification() {
const { notifications } = useNotifications();
const [isPanelOpen, setIsPanelOpen] = useState(false);
const unreadCount = notifications.length;

Expand Down
38 changes: 3 additions & 35 deletions src/components/dashboard/NotificationCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useApiClient } from "@/hooks/useApiClient";
import { Warning, ChatsTeardrop, X } from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
import { Toast } from "react-hot-toast";
Expand Down Expand Up @@ -49,50 +48,19 @@ export default function NotificationCard({
return "now";
};

const { apiClient } = useApiClient();
const router = useRouter();
const { refreshNotifications } = useNotifications();
const { dismissNotification } = useNotifications();

const handleClick = async (redirect: boolean) => {
if (!actionUrl) return;

if (t) toast.dismiss(t.id);

if (!isChat && Number(id) > 0) {
try {
await apiClient.delete(`/api/notifications/${id}`);
} catch (error) {
console.error(`Failed to delete notification ${id}: ${error}`);
}

try {
await refreshNotifications();
} catch (error) {
console.error("Failed to refresh notifications", error);
}
}

await dismissNotification(id);
if (redirect) router.push(actionUrl);
};

const handleClose = async () => {
// If toast, then dismiss
// If stored in db, then delete
if (t) toast.dismiss(t.id);

if (!isChat && Number(id) > 0) {
try {
await apiClient.delete(`/api/notifications/${id}`);
} catch (error) {
console.error(`Failed to delete notification ${id}: ${error}`);
}

try {
await refreshNotifications();
} catch (error) {
console.error("Failed to refresh notifications", error);
}
}
await dismissNotification(id);
};

const styles = isChat
Expand Down
Loading