From 59151efa83a0a92bec861879d8565cdad8e2d9f8 Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 25 Jun 2025 09:19:39 +0000 Subject: [PATCH 01/44] Add Announcements component to Header for improved user notifications --- .../announcement/AnnouncementItem.tsx | 155 +++++++++++++++++ .../components/announcement/Announcements.tsx | 117 +++++++++++++ .../announcement/announcementList.ts | 162 ++++++++++++++++++ .../frontend/src/components/common/Drawer.tsx | 36 ++++ .../frontend/src/components/layout/Header.tsx | 82 ++++----- 5 files changed, 513 insertions(+), 39 deletions(-) create mode 100644 echo/frontend/src/components/announcement/AnnouncementItem.tsx create mode 100644 echo/frontend/src/components/announcement/Announcements.tsx create mode 100644 echo/frontend/src/components/announcement/announcementList.ts create mode 100644 echo/frontend/src/components/common/Drawer.tsx diff --git a/echo/frontend/src/components/announcement/AnnouncementItem.tsx b/echo/frontend/src/components/announcement/AnnouncementItem.tsx new file mode 100644 index 00000000..2432c9ad --- /dev/null +++ b/echo/frontend/src/components/announcement/AnnouncementItem.tsx @@ -0,0 +1,155 @@ +import { + ActionIcon, + Box, + Button, + Collapse, + Group, + Stack, + Text, + useMantineTheme, +} from "@mantine/core"; +import { + IconAlertTriangle, + IconChecks, + IconChevronDown, + IconChevronUp, + IconInfoCircle, +} from "@tabler/icons-react"; +import { Trans } from "@lingui/react/macro"; +import { useDisclosure } from "@mantine/hooks"; +import { useEffect, useRef, useState } from "react"; + +type Announcement = { + id: number; + title: string; + message: string; + created_at: string; + expires_at?: string; + read: boolean; + level: "info" | "urgent"; +}; + +interface AnnouncementItemProps { + announcement: Announcement; + onMarkAsRead: (id: number) => void; + index: number; +} + +export const AnnouncementItem = ({ + announcement, + onMarkAsRead, + index, +}: AnnouncementItemProps) => { + const theme = useMantineTheme(); + const [showMore, setShowMore] = useState(false); + const [showReadMoreButton, setShowReadMoreButton] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + console.log(ref.current.scrollHeight, ref.current.clientHeight); + setShowReadMoreButton( + ref.current.scrollHeight !== ref.current.clientHeight, + ); + } + }, []); + // }, [announcement.message, showMore]); + + return ( + + + + { + + } + + + {announcement.title} + + + + {announcement.created_at} + + + {/* this part needs a second look */} + {!announcement.read && ( +
+ )} + {/* this part needs a second look */} + + + + + {announcement.message} + + + + {showReadMoreButton && ( + + )} + + {/* this part needs a second look */} + + {!announcement.read ? ( + + ) : ( + + + + Marked as read + + + )} + + {/* this part needs a second look */} + + + + + + ); +}; diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx new file mode 100644 index 00000000..43a95b8e --- /dev/null +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -0,0 +1,117 @@ +import { + ActionIcon, + Badge, + Box, + Button, + Divider, + Group, + Indicator, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconBell, IconX } from "@tabler/icons-react"; +import { Trans } from "@lingui/react/macro"; +import { useState } from "react"; +import { Drawer } from "../common/Drawer"; +import { AnnouncementItem } from "./AnnouncementItem"; + +import { initialAnnouncements } from "./announcementList"; + +export const Announcements = () => { + const [opened, { open, close }] = useDisclosure(false); + const [announcements, setAnnouncements] = useState(initialAnnouncements); + + const handleMarkAsRead = (id: number) => { + setAnnouncements((prev) => + prev.map((n) => (n.id === id ? { ...n, read: true } : n)), + ); + }; + + const handleMarkAllAsRead = () => { + setAnnouncements((prev) => prev.map((n) => ({ ...n, read: true }))); + }; + + const unreadCount = announcements.filter((n) => !n.read).length; + + return ( + <> + {/* done and checked */} + + + + + + + + + {/* done and checked */} + + + + Announcements + + + + + + + {unreadCount > 0 && ( + + {unreadCount} unread announcements + + )} + + + + } + classNames={{ + title: "w-full", + body: "p-0", + }} + withCloseButton={false} + > + + + + {announcements.map((announcement, index) => ( + + ))} + + + + + + ); +}; diff --git a/echo/frontend/src/components/announcement/announcementList.ts b/echo/frontend/src/components/announcement/announcementList.ts new file mode 100644 index 00000000..93b56c59 --- /dev/null +++ b/echo/frontend/src/components/announcement/announcementList.ts @@ -0,0 +1,162 @@ +// This will come from an API call later instead of being hardcoded +export type AnnouncementLevel = "info" | "urgent"; + +export type Announcement = { + id: number; + read: boolean; + title: string; + message: string; + created_at: string; + expires_at?: string; + level: AnnouncementLevel; + projectId?: string; +}; + +export const initialAnnouncements: Announcement[] = [ + { + id: 1, + read: false, + title: "New conversation started", + projectId: "1", + message: + "A participant has joined your project and started a conversation. This is a longer description to test the expand/collapse functionality. It should be truncated to two lines by default.", + created_at: "6h ago", + expires_at: "12h ago", + level: "info", + }, + { + id: 22, + read: false, + title: "Audio response received", + projectId: "2", + message: "New audio response uploaded to your project", + created_at: "7h ago", + expires_at: "12h ago", + level: "info", + }, + { + id: 344, + read: true, + title: "Report generated", + projectId: "3", + message: + "Your analytics report has been updated with new data ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "7h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 433, + read: true, + title: "Project shared", + projectId: "4", + message: "Your project has been shared with new collaborators", + created_at: "8h ago", + expires_at: "12h ago", + level: "info", + }, + { + id: 522, + read: true, + title: "Connection status", + projectId: "5", + message: + "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 511, + read: true, + title: "Connection status", + projectId: "5", + message: "Your project connection is healthy and ready for participants", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 500, + read: true, + title: "Connection status", + projectId: "5", + message: + "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 50, + read: true, + title: "Connection status", + projectId: "5", + message: + "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 51, + read: true, + title: "Connection status", + projectId: "5", + message: "Your project connection is healthy and ready for participants", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 52, + read: true, + title: "Connection status", + projectId: "5", + message: "Your project connection is healthy and ready for participants", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 53, + read: true, + title: "Connection status", + projectId: "5", + message: + "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "8h ago", + level: "urgent", + }, + { + id: 54, + read: true, + title: "Connection status", + projectId: "5", + message: "Your project connection is healthy and ready for participants", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, + { + id: 5, + read: true, + title: "Connection status", + projectId: "5", + message: + "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", + created_at: "8h ago", + level: "urgent", + }, + { + id: 55, + read: true, + title: "Connection status", + projectId: "5", + message: "Your project connection is healthy and ready for participants", + created_at: "8h ago", + expires_at: "12h ago", + level: "urgent", + }, +]; +// This will come from an API call later instead of being hardcoded diff --git a/echo/frontend/src/components/common/Drawer.tsx b/echo/frontend/src/components/common/Drawer.tsx new file mode 100644 index 00000000..6a6a0018 --- /dev/null +++ b/echo/frontend/src/components/common/Drawer.tsx @@ -0,0 +1,36 @@ +import { + Drawer as MantineDrawer, + DrawerProps as MantineDrawerProps, + Text, +} from "@mantine/core"; +import { ReactNode } from "react"; + +type DrawerProps = Partial & { + opened: boolean; + onClose: () => void; + title?: ReactNode; + children?: ReactNode; +}; + +export const Drawer = ({ + opened, + onClose, + title, + children, + position = "right", + size = "md", + ...rest +}: DrawerProps) => { + return ( + + {children} + + ); +}; diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx index 1d4002ff..7f056a91 100644 --- a/echo/frontend/src/components/layout/Header.tsx +++ b/echo/frontend/src/components/layout/Header.tsx @@ -25,6 +25,7 @@ import { useState, useEffect } from "react"; import * as Sentry from "@sentry/react"; import { useLanguage } from "@/hooks/useLanguage"; import { useParams } from "react-router-dom"; +import { Announcements } from "../announcement/Announcements"; const User = ({ name, email }: { name: string; email: string }) => (
{ {!loading && isAuthenticated && user ? ( - - - - - - - - - - - - - } - component="a" - href={docUrl} - target="_blank" - > - - Documentation - - - - - - } onClick={handleLogout}> - Logout - - - - - - - - + + + + + + + + + + + + + + + } + component="a" + href={docUrl} + target="_blank" + > + + Documentation + + + + + + } onClick={handleLogout}> + Logout + + + + + + + + + ) : ( From f915465817710464ebabcd20c37e80ce662173bf Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 25 Jun 2025 09:28:04 +0000 Subject: [PATCH 02/44] Update Announcements component icons and adjust notification indicator styling --- .../frontend/src/components/announcement/Announcements.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 43a95b8e..055e80ed 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -11,7 +11,7 @@ import { Text, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { IconBell, IconX } from "@tabler/icons-react"; +import { IconSpeakerphone, IconX } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; import { useState } from "react"; import { Drawer } from "../common/Drawer"; @@ -40,7 +40,8 @@ export const Announcements = () => { {/* done and checked */} { withBorder > - + From 71ce23d125ec8f8bd4c4724300cb03b5565b9a05 Mon Sep 17 00:00:00 2001 From: Usama Date: Thu, 26 Jun 2025 11:43:44 +0000 Subject: [PATCH 03/44] Refactor AnnouncementItem and Announcements components for improved functionality and styling - Updated AnnouncementItem to handle announcements with string IDs and added a date formatting function. - Enhanced the Announcements component to fetch announcements from an API, replacing hardcoded data. - Introduced loading states and error handling for marking announcements as read. - Removed the deprecated announcementList file and adjusted related types in Directus definitions. --- .../announcement/AnnouncementDrawerHeader.tsx | 47 +++++ .../announcement/AnnouncementItem.tsx | 107 ++++++++---- .../announcement/AnnouncementSkeleton.tsx | 21 +++ .../components/announcement/Announcements.tsx | 160 ++++++++++------- .../announcement/announcementList.ts | 162 ------------------ .../src/hooks/useProcessedAnnouncements.ts | 24 +++ echo/frontend/src/lib/query.ts | 80 ++++++++- echo/frontend/src/lib/typesDirectus.d.ts | 29 ++++ echo/frontend/src/lib/typesDirectusContent.ts | 29 ++++ 9 files changed, 400 insertions(+), 259 deletions(-) create mode 100644 echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx create mode 100644 echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx delete mode 100644 echo/frontend/src/components/announcement/announcementList.ts create mode 100644 echo/frontend/src/hooks/useProcessedAnnouncements.ts diff --git a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx new file mode 100644 index 00000000..d40ab2c4 --- /dev/null +++ b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx @@ -0,0 +1,47 @@ +import { Trans } from "@lingui/react/macro"; +import { ActionIcon, Button, Group, Stack, Text } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; + +export const AnnouncementDrawerHeader = ({ + unreadCount, + onClose, + onMarkAllAsRead, + isPending, +}: { + unreadCount: number; + onClose: () => void; + onMarkAllAsRead: () => void; + isPending: boolean; +}) => ( + + + + Announcements + + + + + + + {unreadCount > 0 && ( + + {unreadCount} unread announcements + + )} + + + +); diff --git a/echo/frontend/src/components/announcement/AnnouncementItem.tsx b/echo/frontend/src/components/announcement/AnnouncementItem.tsx index 2432c9ad..5581b922 100644 --- a/echo/frontend/src/components/announcement/AnnouncementItem.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementItem.tsx @@ -1,44 +1,70 @@ import { - ActionIcon, Box, Button, - Collapse, Group, Stack, Text, useMantineTheme, + Loader, + ThemeIcon, } from "@mantine/core"; import { - IconAlertTriangle, IconChecks, IconChevronDown, IconChevronUp, IconInfoCircle, + IconUrgent, } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; -import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef, useState } from "react"; +import { Markdown } from "@/components/common/Markdown"; type Announcement = { - id: number; + id: string; title: string; message: string; - created_at: string; - expires_at?: string; - read: boolean; + created_at: string | Date | null | undefined; + expires_at?: string | Date | null | undefined; + read?: boolean | null; level: "info" | "urgent"; }; interface AnnouncementItemProps { announcement: Announcement; - onMarkAsRead: (id: number) => void; + onMarkAsRead: (id: string) => void; index: number; + isMarkingAsRead?: boolean; } +// TODO: need to check this function +const formatDate = (date: string | Date | null | undefined): string => { + if (!date) return ""; + + // Convert string to Date object if needed + const dateObj = typeof date === "string" ? new Date(date) : date; + + // Check if the date is valid + if (isNaN(dateObj.getTime())) return ""; + + const now = new Date(); + const diffInHours = Math.floor( + (now.getTime() - dateObj.getTime()) / (1000 * 60 * 60), + ); + + if (diffInHours < 1) return "Just now"; + if (diffInHours < 24) return `${diffInHours}h ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) return `${diffInDays}d ago`; + + return dateObj.toLocaleDateString(); +}; + export const AnnouncementItem = ({ announcement, onMarkAsRead, index, + isMarkingAsRead = false, }: AnnouncementItemProps) => { const theme = useMantineTheme(); const [showMore, setShowMore] = useState(false); @@ -53,31 +79,40 @@ export const AnnouncementItem = ({ ); } }, []); - // }, [announcement.message, showMore]); return ( { - + + {announcement.level === "urgent" ? ( + + ) : ( + + )} + } - {announcement.title} +
+ +
- {announcement.created_at} + {formatDate(announcement.created_at)} {/* this part needs a second look */} @@ -95,13 +130,11 @@ export const AnnouncementItem = ({
- - {announcement.message} + + @@ -126,26 +159,28 @@ export const AnnouncementItem = ({ )} - {/* this part needs a second look */} - {!announcement.read ? ( + {!announcement.read && ( - ) : ( - - - - Marked as read - - )} - {/* this part needs a second look */}
diff --git a/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx b/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx new file mode 100644 index 00000000..71bfc74d --- /dev/null +++ b/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx @@ -0,0 +1,21 @@ +import { Box } from "@mantine/core"; + +import { Stack } from "@mantine/core"; + +export const AnnouncementSkeleton = () => ( + + {[1, 2, 3].map((i) => ( + + + + + + ))} + +); diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 055e80ed..13d245cf 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -1,114 +1,154 @@ import { ActionIcon, - Badge, Box, - Button, - Divider, - Group, Indicator, ScrollArea, Stack, Text, + Loader, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { IconSpeakerphone, IconX } from "@tabler/icons-react"; +import { IconSpeakerphone } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; import { useState } from "react"; import { Drawer } from "../common/Drawer"; import { AnnouncementItem } from "./AnnouncementItem"; - -import { initialAnnouncements } from "./announcementList"; +import { + useAnnouncements, + useMarkAnnouncementAsReadMutation, + useCurrentUser, +} from "@/lib/query"; +import { useLanguage } from "@/hooks/useLanguage"; +import { AnnouncementSkeleton } from "./AnnouncementSkeleton"; +import { AnnouncementDrawerHeader } from "./AnnouncementDrawerHeader"; +import { useProcessedAnnouncements } from "@/hooks/useProcessedAnnouncements"; export const Announcements = () => { const [opened, { open, close }] = useDisclosure(false); - const [announcements, setAnnouncements] = useState(initialAnnouncements); + const { data: announcements = [], isLoading, error } = useAnnouncements(); + const { language } = useLanguage(); + const { data: currentUser } = useCurrentUser(); + const markAsReadMutation = useMarkAnnouncementAsReadMutation(); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + + // Process announcements with translations and read status + const processedAnnouncements = useProcessedAnnouncements( + announcements as Announcement[], + language, + ); + + const unreadAnnouncements = processedAnnouncements.filter((a) => !a.read); + const unreadCount = unreadAnnouncements.length; + + const handleMarkAsRead = async (id: string) => { + if (!currentUser?.id) { + console.error("No current user found"); + return; + } - const handleMarkAsRead = (id: number) => { - setAnnouncements((prev) => - prev.map((n) => (n.id === id ? { ...n, read: true } : n)), - ); + setMarkingAsReadId(id); + + try { + await markAsReadMutation.mutateAsync({ + announcementIds: [id], + userId: currentUser.id, + }); + } catch (error) { + console.error("Failed to mark announcement as read:", error); + } finally { + setMarkingAsReadId(null); + } }; - const handleMarkAllAsRead = () => { - setAnnouncements((prev) => prev.map((n) => ({ ...n, read: true }))); + const handleMarkAllAsRead = async () => { + if (!currentUser?.id) { + console.error("No current user found"); + return; + } + + try { + // Extract all unread announcement IDs + const unreadIds = unreadAnnouncements.map( + (announcement) => announcement.id, + ); + + // Mark all unread announcements as read in one call + await markAsReadMutation.mutateAsync({ + announcementIds: unreadIds as string[], + userId: currentUser.id, + }); + } catch (error) { + console.error("Failed to mark all announcements as read:", error); + } }; - const unreadCount = announcements.filter((n) => !n.read).length; + if (error) { + console.error("Error loading announcements:", error); + } return ( <> - {/* done and checked */} - + {isLoading ? ( + + ) : ( + + )} - {/* done and checked */} - - - Announcements - - - - - - - {unreadCount > 0 && ( - - {unreadCount} unread announcements - - )} - - -
+ } classNames={{ - title: "w-full", + title: "px-3 w-full", + header: "border-b", body: "p-0", }} withCloseButton={false} > - + - {announcements.map((announcement, index) => ( - - ))} + {isLoading ? ( + + ) : processedAnnouncements.length === 0 ? ( + + + No announcements available + + + ) : ( + processedAnnouncements.map((announcement, index) => ( + + )) + )} diff --git a/echo/frontend/src/components/announcement/announcementList.ts b/echo/frontend/src/components/announcement/announcementList.ts deleted file mode 100644 index 93b56c59..00000000 --- a/echo/frontend/src/components/announcement/announcementList.ts +++ /dev/null @@ -1,162 +0,0 @@ -// This will come from an API call later instead of being hardcoded -export type AnnouncementLevel = "info" | "urgent"; - -export type Announcement = { - id: number; - read: boolean; - title: string; - message: string; - created_at: string; - expires_at?: string; - level: AnnouncementLevel; - projectId?: string; -}; - -export const initialAnnouncements: Announcement[] = [ - { - id: 1, - read: false, - title: "New conversation started", - projectId: "1", - message: - "A participant has joined your project and started a conversation. This is a longer description to test the expand/collapse functionality. It should be truncated to two lines by default.", - created_at: "6h ago", - expires_at: "12h ago", - level: "info", - }, - { - id: 22, - read: false, - title: "Audio response received", - projectId: "2", - message: "New audio response uploaded to your project", - created_at: "7h ago", - expires_at: "12h ago", - level: "info", - }, - { - id: 344, - read: true, - title: "Report generated", - projectId: "3", - message: - "Your analytics report has been updated with new data ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "7h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 433, - read: true, - title: "Project shared", - projectId: "4", - message: "Your project has been shared with new collaborators", - created_at: "8h ago", - expires_at: "12h ago", - level: "info", - }, - { - id: 522, - read: true, - title: "Connection status", - projectId: "5", - message: - "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 511, - read: true, - title: "Connection status", - projectId: "5", - message: "Your project connection is healthy and ready for participants", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 500, - read: true, - title: "Connection status", - projectId: "5", - message: - "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 50, - read: true, - title: "Connection status", - projectId: "5", - message: - "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 51, - read: true, - title: "Connection status", - projectId: "5", - message: "Your project connection is healthy and ready for participants", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 52, - read: true, - title: "Connection status", - projectId: "5", - message: "Your project connection is healthy and ready for participants", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 53, - read: true, - title: "Connection status", - projectId: "5", - message: - "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "8h ago", - level: "urgent", - }, - { - id: 54, - read: true, - title: "Connection status", - projectId: "5", - message: "Your project connection is healthy and ready for participants", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, - { - id: 5, - read: true, - title: "Connection status", - projectId: "5", - message: - "Your project connection is healthy and ready for participants ur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new dataur analytics report has been updated with new data", - created_at: "8h ago", - level: "urgent", - }, - { - id: 55, - read: true, - title: "Connection status", - projectId: "5", - message: "Your project connection is healthy and ready for participants", - created_at: "8h ago", - expires_at: "12h ago", - level: "urgent", - }, -]; -// This will come from an API call later instead of being hardcoded diff --git a/echo/frontend/src/hooks/useProcessedAnnouncements.ts b/echo/frontend/src/hooks/useProcessedAnnouncements.ts new file mode 100644 index 00000000..fe6bcadc --- /dev/null +++ b/echo/frontend/src/hooks/useProcessedAnnouncements.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; + +export function useProcessedAnnouncements( + announcements: Announcement[], + language: string, +) { + return useMemo(() => { + return announcements.map((announcement) => { + const translation = + announcement.translations?.find((t) => t.languages_code === language) || + announcement.translations?.[0]; + + return { + id: announcement.id, + title: translation?.title || "", + message: translation?.message || "", + created_at: announcement.created_at, + expires_at: announcement.expires_at, + level: announcement.level as "info" | "urgent", + read: announcement.activity?.[0]?.read || false, + }; + }); + }, [announcements, language]); +} diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index 91394ced..04c02679 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2180,4 +2180,82 @@ export const useSubmitNotificationParticipant = () => { console.error("Notification submission failed:", error); }, }); -}; \ No newline at end of file +}; + +export const useAnnouncements = ({ + query = {}, +}: { + query?: Partial>; +} = {}) => { + return useQuery({ + queryKey: ["announcements", query], + queryFn: async () => { + const announcements = await directus.request( + readItems("announcement", { + filter: { + _or: [ + { + expires_at: { + // @ts-expect-error + _gte: new Date().toISOString(), + }, + }, + { + expires_at: { + _null: true, + }, + }, + ], + }, + fields: [ + "id", + "created_at", + "expires_at", + "level", + { + translations: ["id", "languages_code", "title", "message"], + }, + { + activity: ["id", "user_id", "announcement_activity", "read"], + }, + ], + sort: ["-created_at"], + ...query, + }), + ); + console.log(announcements, " ===> announcements query"); + return announcements; + }, + }); +}; + +export const useMarkAnnouncementAsReadMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + announcementIds, + userId, + }: { + announcementIds: string[]; + userId: string; + }) => { + return directus.request( + createItems( + "announcement_activity", + announcementIds.map((id) => ({ + user_id: userId, + announcement_activity: id, + read: true, + })) as any, + ), + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["announcements"] }); + }, + onError: (error) => { + console.error("Error marking announcement as read:", error); + toast.error("Failed to mark announcement as read"); + }, + }); +}; diff --git a/echo/frontend/src/lib/typesDirectus.d.ts b/echo/frontend/src/lib/typesDirectus.d.ts index 275709cd..246e353d 100644 --- a/echo/frontend/src/lib/typesDirectus.d.ts +++ b/echo/frontend/src/lib/typesDirectus.d.ts @@ -716,6 +716,32 @@ type View = { updated_at?: string | null; }; +type Announcement = { + id: string; + user_created?: string | DirectusUsers | null; + created_at?: Date | null; + expires_at?: Date | null; + level?: string | null; + translations: any[] | AnnouncementTranslations[]; + activity: any[] | AnnouncementActivity[]; +}; + +type AnnouncementTranslations = { + id: number; + languages_code?: string | Languages | null; + title?: string | null; + message?: string | null; +}; + +type AnnouncementActivity = { + id: string; + user_created?: string | DirectusUsers | null; + created_at?: string | null; + user_id?: string | null; + announcement_activity?: string | Announcement | null; + read: boolean | null; +}; + type CustomDirectusTypes = { account: Account[]; account_directus_users: AccountDirectusUsers[]; @@ -771,4 +797,7 @@ type CustomDirectusTypes = { quote_conversation_chunk: QuoteConversationChunk[]; view: View[]; processing_status: ProcessingStatus[]; + announcement: Announcement[]; + announcement_translations: AnnouncementTranslations[]; + announcement_activity: AnnouncementActivity[]; }; diff --git a/echo/frontend/src/lib/typesDirectusContent.ts b/echo/frontend/src/lib/typesDirectusContent.ts index a4aa7979..7554343f 100644 --- a/echo/frontend/src/lib/typesDirectusContent.ts +++ b/echo/frontend/src/lib/typesDirectusContent.ts @@ -1096,6 +1096,32 @@ export type TestimonialsTranslations = { testimonials_id?: string | Testimonials | null; }; +export type Announcement = { + id: string; + user_created?: string | DirectusUsers | null; + created_at?: Date | null | undefined; + expires_at?: Date | null | undefined; + level?: string | null; + translations: any[] | AnnouncementTranslations[]; + activity: any[] | AnnouncementActivity[]; +}; + +export type AnnouncementTranslations = { + id: number; + languages_code?: string | Languages | null; + title?: string | null; + message?: string | null; +}; + +export type AnnouncementActivity = { + id: string; + user_created?: string | DirectusUsers | null; + created_at?: string | null; + user_id?: string | null; + announcement_activity?: string | Announcement | null; + read: boolean | null; +}; + export type CustomDirectusTypes = { block_button: BlockButton; block_button_group: BlockButtonGroup; @@ -1200,4 +1226,7 @@ export type CustomDirectusTypes = { team: Team; testimonials: Testimonials; testimonials_translations: TestimonialsTranslations; + announcement: Announcement; + announcement_translations: AnnouncementTranslations; + announcement_activity: AnnouncementActivity; }; From 4830eca572684334d840d242dbe2a766f2ed865e Mon Sep 17 00:00:00 2001 From: Usama Date: Thu, 26 Jun 2025 12:27:50 +0000 Subject: [PATCH 04/44] Refactor AnnouncementItem and Announcements components for improved functionality and performance - Updated AnnouncementItem to use forwardRef for better ref handling and adjusted internal refs for message display. - Enhanced Announcements component to utilize useInfiniteAnnouncements for paginated fetching of announcements. - Implemented loading state for fetching more announcements and improved error handling. - Cleaned up code for better readability and maintainability. --- .../announcement/AnnouncementItem.tsx | 30 ++++---- .../components/announcement/Announcements.tsx | 68 +++++++++++++++---- echo/frontend/src/lib/query.ts | 38 ++++++++--- 3 files changed, 100 insertions(+), 36 deletions(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementItem.tsx b/echo/frontend/src/components/announcement/AnnouncementItem.tsx index 5581b922..689e7f99 100644 --- a/echo/frontend/src/components/announcement/AnnouncementItem.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementItem.tsx @@ -16,7 +16,7 @@ import { IconUrgent, } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, forwardRef } from "react"; import { Markdown } from "@/components/common/Markdown"; type Announcement = { @@ -60,28 +60,30 @@ const formatDate = (date: string | Date | null | undefined): string => { return dateObj.toLocaleDateString(); }; -export const AnnouncementItem = ({ - announcement, - onMarkAsRead, - index, - isMarkingAsRead = false, -}: AnnouncementItemProps) => { +export const AnnouncementItem = forwardRef< + HTMLDivElement, + AnnouncementItemProps +>(({ announcement, onMarkAsRead, index, isMarkingAsRead = false }, ref) => { const theme = useMantineTheme(); const [showMore, setShowMore] = useState(false); const [showReadMoreButton, setShowReadMoreButton] = useState(false); - const ref = useRef(null); + const messageRef = useRef(null); useEffect(() => { - if (ref.current) { - console.log(ref.current.scrollHeight, ref.current.clientHeight); + if (messageRef.current) { + console.log( + messageRef.current.scrollHeight, + messageRef.current.clientHeight, + ); setShowReadMoreButton( - ref.current.scrollHeight !== ref.current.clientHeight, + messageRef.current.scrollHeight !== messageRef.current.clientHeight, ); } }, []); return (
- + ); -}; +}); + +AnnouncementItem.displayName = "AnnouncementItem"; diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 13d245cf..8437d4f2 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -6,15 +6,17 @@ import { Stack, Text, Loader, + Center, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconSpeakerphone } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useInView } from "react-intersection-observer"; import { Drawer } from "../common/Drawer"; import { AnnouncementItem } from "./AnnouncementItem"; import { - useAnnouncements, + useInfiniteAnnouncements, useMarkAnnouncementAsReadMutation, useCurrentUser, } from "@/lib/query"; @@ -25,21 +27,47 @@ import { useProcessedAnnouncements } from "@/hooks/useProcessedAnnouncements"; export const Announcements = () => { const [opened, { open, close }] = useDisclosure(false); - const { data: announcements = [], isLoading, error } = useAnnouncements(); const { language } = useLanguage(); const { data: currentUser } = useCurrentUser(); const markAsReadMutation = useMarkAnnouncementAsReadMutation(); const [markingAsReadId, setMarkingAsReadId] = useState(null); + const { ref: loadMoreRef, inView } = useInView(); + + const { + data: announcementsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + error, + } = useInfiniteAnnouncements({ + options: { + initialLimit: 10, + }, + }); + + // Flatten all announcements from all pages + const allAnnouncements = + announcementsData?.pages.flatMap((page) => page.announcements) ?? []; + // Process announcements with translations and read status const processedAnnouncements = useProcessedAnnouncements( - announcements as Announcement[], + allAnnouncements as Announcement[], language, ); const unreadAnnouncements = processedAnnouncements.filter((a) => !a.read); const unreadCount = unreadAnnouncements.length; + // Load more announcements when user scrolls to bottom + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + const handleMarkAsRead = async (id: string) => { if (!currentUser?.id) { console.error("No current user found"); @@ -82,7 +110,7 @@ export const Announcements = () => { } }; - if (error) { + if (isError) { console.error("Error loading announcements:", error); } @@ -139,15 +167,27 @@ export const Announcements = () => { ) : ( - processedAnnouncements.map((announcement, index) => ( - - )) + <> + {processedAnnouncements.map((announcement, index) => ( + + ))} + {isFetchingNextPage && ( +
+ +
+ )} + )} diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index 04c02679..d4b515eb 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2182,15 +2182,23 @@ export const useSubmitNotificationParticipant = () => { }); }; -export const useAnnouncements = ({ - query = {}, +export const useInfiniteAnnouncements = ({ + query, + options = { + initialLimit: 10, + }, }: { query?: Partial>; -} = {}) => { - return useQuery({ - queryKey: ["announcements", query], - queryFn: async () => { - const announcements = await directus.request( + options?: { + initialLimit?: number; + }; +}) => { + const { initialLimit = 10 } = options; + + return useInfiniteQuery({ + queryKey: ["announcements", "infinite", query], + queryFn: async ({ pageParam = 0 }) => { + const response = await directus.request( readItems("announcement", { filter: { _or: [ @@ -2220,12 +2228,20 @@ export const useAnnouncements = ({ }, ], sort: ["-created_at"], + limit: initialLimit, + offset: pageParam * initialLimit, ...query, }), ); - console.log(announcements, " ===> announcements query"); - return announcements; + + return { + announcements: response, + nextOffset: + response.length === initialLimit ? pageParam + 1 : undefined, + }; }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, }); }; @@ -2251,7 +2267,11 @@ export const useMarkAnnouncementAsReadMutation = () => { ); }, onSuccess: () => { + // Invalidate both regular and infinite announcements queries queryClient.invalidateQueries({ queryKey: ["announcements"] }); + queryClient.invalidateQueries({ + queryKey: ["announcements", "infinite"], + }); }, onError: (error) => { console.error("Error marking announcement as read:", error); From 2c15cf971352cb8d7dff3a3a1271d93448f78f57 Mon Sep 17 00:00:00 2001 From: Usama Date: Fri, 27 Jun 2025 09:20:59 +0000 Subject: [PATCH 05/44] Update Announcement components and add latest announcement query - Replaced IconUrgent with IconAlertTriangle in AnnouncementItem for better visual representation. - Refactored Announcements component to use useAnnouncementDrawer for managing drawer state. - Integrated TopAnnouncementBar into Header for enhanced user notifications. - Added useLatestAnnouncement hook to fetch the most recent announcement, improving data handling and user experience. --- .../announcement/AnnouncementItem.tsx | 4 +- .../components/announcement/Announcements.tsx | 5 +- .../announcement/TopAnnouncementBar.tsx | 73 +++++++++++++++++++ .../frontend/src/components/layout/Header.tsx | 4 + .../src/hooks/useAnnouncementDrawer.tsx | 22 ++++++ echo/frontend/src/lib/query.ts | 41 +++++++++++ 6 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 echo/frontend/src/components/announcement/TopAnnouncementBar.tsx create mode 100644 echo/frontend/src/hooks/useAnnouncementDrawer.tsx diff --git a/echo/frontend/src/components/announcement/AnnouncementItem.tsx b/echo/frontend/src/components/announcement/AnnouncementItem.tsx index 689e7f99..3d3f1d39 100644 --- a/echo/frontend/src/components/announcement/AnnouncementItem.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementItem.tsx @@ -13,7 +13,7 @@ import { IconChevronDown, IconChevronUp, IconInfoCircle, - IconUrgent, + IconAlertTriangle, } from "@tabler/icons-react"; import { Trans } from "@lingui/react/macro"; import { useEffect, useRef, useState, forwardRef } from "react"; @@ -100,7 +100,7 @@ export const AnnouncementItem = forwardRef< radius="xl" > {announcement.level === "urgent" ? ( - + ) : ( )} diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 8437d4f2..71eaef57 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -24,9 +24,10 @@ import { useLanguage } from "@/hooks/useLanguage"; import { AnnouncementSkeleton } from "./AnnouncementSkeleton"; import { AnnouncementDrawerHeader } from "./AnnouncementDrawerHeader"; import { useProcessedAnnouncements } from "@/hooks/useProcessedAnnouncements"; +import { useAnnouncementDrawer } from "@/hooks/useAnnouncementDrawer"; export const Announcements = () => { - const [opened, { open, close }] = useDisclosure(false); + const { isOpen, open, close } = useAnnouncementDrawer(); const { language } = useLanguage(); const { data: currentUser } = useCurrentUser(); const markAsReadMutation = useMarkAnnouncementAsReadMutation(); @@ -137,7 +138,7 @@ export const Announcements = () => { { + e.stopPropagation(); + setIsClosed(true); + }; + + const handleBarClick = () => { + open(); + }; + + return ( + + + + + + {displayText} + + + + + + + ); +} diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx index 7f056a91..9863e825 100644 --- a/echo/frontend/src/components/layout/Header.tsx +++ b/echo/frontend/src/components/layout/Header.tsx @@ -26,6 +26,7 @@ import * as Sentry from "@sentry/react"; import { useLanguage } from "@/hooks/useLanguage"; import { useParams } from "react-router-dom"; import { Announcements } from "../announcement/Announcements"; +import { TopAnnouncementBar } from "../announcement/TopAnnouncementBar"; const User = ({ name, email }: { name: string; email: string }) => (
{ }; return ( + <> + {isAuthenticated && user && } { )} + ); }; diff --git a/echo/frontend/src/hooks/useAnnouncementDrawer.tsx b/echo/frontend/src/hooks/useAnnouncementDrawer.tsx new file mode 100644 index 00000000..08eaf918 --- /dev/null +++ b/echo/frontend/src/hooks/useAnnouncementDrawer.tsx @@ -0,0 +1,22 @@ +import useSessionStorageState from "use-session-storage-state"; + +export const useAnnouncementDrawer = () => { + const [isOpen, setIsOpen] = useSessionStorageState( + "announcement-drawer-open", + { + defaultValue: false, + }, + ); + + const open = () => setIsOpen(true); + const close = () => setIsOpen(false); + const toggle = () => setIsOpen(!isOpen); + + return { + isOpen, + setIsOpen, + open, + close, + toggle, + }; +}; diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index d4b515eb..732b20d0 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2182,6 +2182,47 @@ export const useSubmitNotificationParticipant = () => { }); }; +export const useLatestAnnouncement = () => { + return useQuery({ + queryKey: ["announcements", "latest"], + queryFn: async () => { + const response = await directus.request( + readItems("announcement", { + filter: { + _or: [ + { + expires_at: { + // @ts-expect-error + _gte: new Date().toISOString(), + }, + }, + { + expires_at: { + _null: true, + }, + }, + ], + }, + fields: [ + "id", + "created_at", + "expires_at", + "level", + { + translations: ["id", "languages_code", "title", "message"], + }, + ], + sort: ["-created_at"], + limit: 1, + }), + ); + + return response.length > 0 ? response[0] : null; + }, + staleTime: 10 * 60 * 1000, // optional: 10 min cache + }); +}; + export const useInfiniteAnnouncements = ({ query, options = { From 46f744db96585eaabd7a192dbdf5c39d46144e99 Mon Sep 17 00:00:00 2001 From: Usama Date: Fri, 27 Jun 2025 09:40:55 +0000 Subject: [PATCH 06/44] Enhance Announcements and TopAnnouncementBar components for improved styling and functionality - Updated the label in Announcements component to include padding and text size for better visibility. - Adjusted the rotation of the IconSpeakerphone in Announcements for a more polished appearance. - Changed the ThemeIcon variant in TopAnnouncementBar to 'transparent' for a cleaner look. - Increased the size of the IconAlertTriangle in TopAnnouncementBar for better visual impact. --- .../src/components/announcement/Announcements.tsx | 8 ++++++-- .../src/components/announcement/TopAnnouncementBar.tsx | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 71eaef57..6c4d8a48 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -122,7 +122,11 @@ export const Announcements = () => { inline offset={4} color="blue" - label={unreadCount} + label={ + + {unreadCount} + + } size={20} disabled={unreadCount === 0} withBorder @@ -131,7 +135,7 @@ export const Announcements = () => { {isLoading ? ( ) : ( - + )} diff --git a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx index b8aab5ef..f0bfeae4 100644 --- a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx +++ b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx @@ -51,17 +51,17 @@ export function TopAnnouncementBar() { - + {displayText} Date: Fri, 27 Jun 2025 13:23:41 +0000 Subject: [PATCH 07/44] Refactor TopAnnouncementBar and useProcessedAnnouncements for improved translation handling - Integrated language support in TopAnnouncementBar by utilizing the useLanguage hook. - Replaced direct translation access with a new getTranslatedContent function for better translation management. - Updated TopAnnouncementBar to render announcement titles using Markdown for enhanced formatting. - Removed staleTime configuration from useLatestAnnouncement for cleaner data fetching. --- .../announcement/TopAnnouncementBar.tsx | 11 +++++----- .../src/hooks/useProcessedAnnouncements.ts | 21 ++++++++++++++----- echo/frontend/src/lib/query.ts | 1 - 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx index f0bfeae4..af6ac58e 100644 --- a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx +++ b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx @@ -11,12 +11,16 @@ import { useLatestAnnouncement } from "@/lib/query"; import { theme } from "@/theme"; import { useState } from "react"; import { useAnnouncementDrawer } from "@/hooks/useAnnouncementDrawer"; +import { useLanguage } from "@/hooks/useLanguage"; +import { Markdown } from "@/components/common/Markdown"; +import { getTranslatedContent } from "@/hooks/useProcessedAnnouncements"; export function TopAnnouncementBar() { const theme = useMantineTheme(); const { data: announcement, isLoading } = useLatestAnnouncement(); const [isClosed, setIsClosed] = useState(false); const { open } = useAnnouncementDrawer(); + const { language } = useLanguage(); // Only show if we have an urgent announcement and it's not closed if ( @@ -28,10 +32,7 @@ export function TopAnnouncementBar() { return null; } - // Get the first translation or fallback to empty string - const translation = announcement?.translations?.[0]; - const displayText = - translation?.title || translation?.message || "Important announcement"; + const { title } = getTranslatedContent(announcement, language); const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); @@ -57,7 +58,7 @@ export function TopAnnouncementBar() { > - {displayText} + { + const translation = + announcement.translations?.find( + (t: any) => t.languages_code === language && t.title, + ) || + announcement.translations?.find((t: any) => t.languages_code === "en-US"); + + return { + title: translation?.title || "", + message: translation?.message || "", + }; +}; + export function useProcessedAnnouncements( announcements: Announcement[], language: string, ) { return useMemo(() => { return announcements.map((announcement) => { - const translation = - announcement.translations?.find((t) => t.languages_code === language) || - announcement.translations?.[0]; + const { title, message } = getTranslatedContent(announcement, language); return { id: announcement.id, - title: translation?.title || "", - message: translation?.message || "", + title, + message, created_at: announcement.created_at, expires_at: announcement.expires_at, level: announcement.level as "info" | "urgent", diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index 732b20d0..425d19af 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2219,7 +2219,6 @@ export const useLatestAnnouncement = () => { return response.length > 0 ? response[0] : null; }, - staleTime: 10 * 60 * 1000, // optional: 10 min cache }); }; From 8fc7a1bf255b2bc77595c4c468e38d1bf611c0ff Mon Sep 17 00:00:00 2001 From: Usama Date: Fri, 27 Jun 2025 13:42:29 +0000 Subject: [PATCH 08/44] Update AnnouncementItem styling for improved user interaction - Changed button variants in AnnouncementItem to 'transparent' for a cleaner look. - Added 'gray' color to buttons and applied 'hover:underline' class for better visual feedback on hover. --- .../src/components/announcement/AnnouncementItem.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementItem.tsx b/echo/frontend/src/components/announcement/AnnouncementItem.tsx index 3d3f1d39..cd19a1fa 100644 --- a/echo/frontend/src/components/announcement/AnnouncementItem.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementItem.tsx @@ -143,7 +143,9 @@ export const AnnouncementItem = forwardRef< {showReadMoreButton && ( - - -); + + + + + + {unreadCount && parseInt(unreadCount) > 0 && ( + + {unreadCount} unread announcements + + )} + + + + ); +}; diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index f6c11c8c..57413d75 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -56,7 +56,6 @@ export const Announcements = () => { ); const unreadAnnouncements = processedAnnouncements.filter((a) => !a.read); - const unreadCount = unreadAnnouncements.length; // Load more announcements when user scrolls to bottom useEffect(() => { @@ -106,7 +105,6 @@ export const Announcements = () => { position="right" title={ Date: Wed, 2 Jul 2025 08:25:39 +0000 Subject: [PATCH 14/44] Update Announcements component loader styling for improved user experience - Adjusted the loader size from 'sm' to 'md' for better visibility during data fetching. - Added vertical padding to the Center component to enhance layout aesthetics. --- echo/frontend/src/components/announcement/Announcements.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 57413d75..acd52312 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -145,8 +145,8 @@ export const Announcements = () => { /> ))} {isFetchingNextPage && ( -
- +
+
)} From 18202e45c2d67bcedaba547897c25cd8831a640c Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 08:52:37 +0000 Subject: [PATCH 15/44] Refactor AnnouncementSkeleton component for enhanced loading experience - Updated the structure and styling of the AnnouncementSkeleton to provide a more visually appealing loading state. - Increased the number of skeleton items displayed and improved layout with additional icons and spacing for better user interaction. - Implemented hover effects and transitions to enhance the overall user experience during data fetching. --- .../announcement/AnnouncementSkeleton.tsx | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx b/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx index 71bfc74d..9a5d0b00 100644 --- a/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementSkeleton.tsx @@ -1,20 +1,66 @@ -import { Box } from "@mantine/core"; - -import { Stack } from "@mantine/core"; +import { Box, Group, Stack, ThemeIcon } from "@mantine/core"; +import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; export const AnnouncementSkeleton = () => ( - - {[1, 2, 3].map((i) => ( - - - - + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + + + + {/* Use a generic icon skeleton */} + + + + + + + + + + + + + + + + + + + + + + + ))} From 485b1763d41d6800a7b4edbfa2a18b18b198cd93 Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 09:07:18 +0000 Subject: [PATCH 16/44] Refactor AnnouncementIcon component for improved user interaction - Updated the AnnouncementIcon component to enhance clickability by moving the onClick handler to the Group element. - Simplified the structure by removing the cursor-pointer class from the Box element, improving code readability. --- .../frontend/src/components/announcement/AnnouncementIcon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx b/echo/frontend/src/components/announcement/AnnouncementIcon.tsx index efa68575..6e423de8 100644 --- a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementIcon.tsx @@ -29,8 +29,8 @@ export const AnnouncementIcon = () => { const isLoading = isLoadingLatest || isLoadingUnread; return ( - - + + Date: Wed, 2 Jul 2025 11:34:23 +0000 Subject: [PATCH 17/44] Enhance useUnreadAnnouncements hook to improve user-specific announcement tracking - Integrated current user data into the useUnreadAnnouncements hook to conditionally fetch unread announcements based on user ID. - Added logic to return 0 if no user is logged in, ensuring a smoother experience for unauthenticated users. - Updated queryKey to include currentUser ID for more accurate data retrieval and caching. - Enabled the query only when a user is logged in, optimizing performance and reducing unnecessary requests. --- echo/frontend/src/lib/query.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index aa700deb..a2eb6070 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2353,10 +2353,17 @@ export const useMarkAnnouncementAsReadMutation = () => { }; export const useUnreadAnnouncements = () => { + const { data: currentUser } = useCurrentUser(); + return useQuery({ - queryKey: ["announcements", "unread"], + queryKey: ["announcements", "unread", currentUser?.id], queryFn: async () => { try { + // If no user is logged in, return 0 + if (!currentUser?.id) { + return 0; + } + const unreadAnnouncements = await directus.request( aggregate("announcement", { aggregate: { count: "*" }, @@ -2364,8 +2371,13 @@ export const useUnreadAnnouncements = () => { filter: { _and: [ { + // Only count announcements that don't have activity records for this user activity: { - _null: true, + _none: { + user_id: { + _eq: currentUser.id, + }, + }, }, }, { @@ -2394,6 +2406,7 @@ export const useUnreadAnnouncements = () => { return 0; } }, + enabled: !!currentUser?.id, // Only run query if user is logged in retry: 2, staleTime: 1000 * 60 * 5, // 5 minutes }); From b546515b0c897138f51c415d71a3f63e6646cf38 Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 12:17:28 +0000 Subject: [PATCH 18/44] Add markAllAsRead mutation to Announcements component for improved user experience - Introduced a new useMarkAllAnnouncementsAsReadMutation hook to handle marking all announcements as read in a single call. - Updated the Announcements component to utilize the new mutation, simplifying the process of marking all announcements as read. - Enhanced success and error handling with toast notifications for better user feedback during the marking process. --- .../components/announcement/Announcements.tsx | 15 ++-- echo/frontend/src/lib/query.ts | 85 ++++++++++++++++++- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index acd52312..432bb422 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -7,6 +7,7 @@ import { AnnouncementItem } from "./AnnouncementItem"; import { useInfiniteAnnouncements, useMarkAnnouncementAsReadMutation, + useMarkAllAnnouncementsAsReadMutation, } from "@/lib/query"; import { useLanguage } from "@/hooks/useLanguage"; import { AnnouncementSkeleton } from "./AnnouncementSkeleton"; @@ -18,6 +19,7 @@ export const Announcements = () => { const { isOpen, close } = useAnnouncementDrawer(); const { language } = useLanguage(); const markAsReadMutation = useMarkAnnouncementAsReadMutation(); + const markAllAsReadMutation = useMarkAllAnnouncementsAsReadMutation(); const [markingAsReadId, setMarkingAsReadId] = useState(null); const [openedOnce, setOpenedOnce] = useState(false); @@ -80,15 +82,8 @@ export const Announcements = () => { const handleMarkAllAsRead = async () => { try { - // Extract all unread announcement IDs - const unreadIds = unreadAnnouncements.map( - (announcement) => announcement.id, - ); - - // Mark all unread announcements as read in one call - await markAsReadMutation.mutateAsync({ - announcementIds: unreadIds as string[], - }); + // Use the new dedicated mutation for marking all as read + await markAllAsReadMutation.mutateAsync(); } catch (error) { console.error("Failed to mark all announcements as read:", error); } @@ -107,7 +102,7 @@ export const Announcements = () => { } classNames={{ diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index a2eb6070..60920692 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2344,10 +2344,93 @@ export const useMarkAnnouncementAsReadMutation = () => { queryClient.invalidateQueries({ queryKey: ["announcements", "unread"], }); + toast.success(t`Announcement marked as read successfully!`); }, onError: (error) => { console.error("Error marking announcement as read:", error); - toast.error("Failed to mark announcement as read"); + toast.error(t`Failed to mark announcement as read`); + }, + }); +}; + +export const useMarkAllAnnouncementsAsReadMutation = () => { + const queryClient = useQueryClient(); + const { data: currentUser } = useCurrentUser(); + + return useMutation({ + mutationFn: async () => { + try { + // Step 1: Find all announcement IDs that don't have activity for this user + const unreadAnnouncements = await directus.request( + readItems("announcement", { + filter: { + _and: [ + { + // Only get announcements that don't have activity records for this user + activity: { + _none: { + user_id: { + _eq: currentUser?.id, + }, + }, + }, + }, + { + _or: [ + { + expires_at: { + // @ts-ignore + _gte: new Date().toISOString(), + }, + }, + { + expires_at: { + _null: true, + }, + }, + ], + }, + ], + }, + fields: ["id"], + }), + ); + + // Step 2: Create activity records for all unread announcements + if (unreadAnnouncements.length > 0) { + return await directus.request( + createItems( + "announcement_activity", + unreadAnnouncements.map((announcement) => ({ + announcement_activity: announcement.id, + read: true, + ...(currentUser?.id ? { user_id: currentUser.id } : {}), + })) as any, + ), + ); + } + + return []; + } catch (error) { + console.error("Error in markAllAsRead mutationFn:", error); + throw error; + } + }, + onSuccess: () => { + // Invalidate all announcement queries + queryClient.invalidateQueries({ queryKey: ["announcements"] }); + queryClient.invalidateQueries({ + queryKey: ["announcements", "infinite"], + }); + queryClient.invalidateQueries({ + queryKey: ["announcements", "unread"], + }); + + toast.success(t`All announcements marked as read successfully!`); + }, + onError: (error) => { + console.error("Error marking all announcements as read:", error); + toast.error(t`Failed to mark all announcements as read`); }, }); }; From ced2679bb69a2de9b6d02d225ea797516655af1f Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 12:23:39 +0000 Subject: [PATCH 19/44] Refactor AnnouncementDrawerHeader and AnnouncementIcon for improved button visibility and interaction - Updated AnnouncementDrawerHeader to conditionally render the "Mark all read" button only when there are unread announcements, enhancing user experience. - Modified AnnouncementIcon to ensure the disabled state is correctly determined by parsing the unreadCount, improving interaction reliability. --- .../announcement/AnnouncementDrawerHeader.tsx | 21 ++++++++++--------- .../announcement/AnnouncementIcon.tsx | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx index 0c109002..96ebf223 100644 --- a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx @@ -34,16 +34,17 @@ export const AnnouncementDrawerHeader = ({ {unreadCount} unread announcements )} - + {unreadCount && parseInt(unreadCount) > 0 && ( + + )} ); diff --git a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx b/echo/frontend/src/components/announcement/AnnouncementIcon.tsx index 6e423de8..c9a4fe44 100644 --- a/echo/frontend/src/components/announcement/AnnouncementIcon.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementIcon.tsx @@ -41,7 +41,7 @@ export const AnnouncementIcon = () => { } size={20} - disabled={(unreadCount || 0) === 0} + disabled={(parseInt(unreadCount || "0") || 0) === 0} withBorder > From ec1bcd21540fe690a0b38ab7b54a72d3c31c37fe Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 13:18:59 +0000 Subject: [PATCH 20/44] Refactor AnnouncementDrawerHeader and useUnreadAnnouncements hook for improved logic and performance - Updated AnnouncementDrawerHeader to simplify unreadCount checks by removing unnecessary parsing. - Refactored useUnreadAnnouncements hook to enhance query logic, changing from _and to _or for better filtering of announcements based on expiration status. - Improved count calculation for unread announcements by directly parsing the counts, ensuring accurate results. --- .../announcement/AnnouncementDrawerHeader.tsx | 4 +- echo/frontend/src/lib/query.ts | 46 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx index 96ebf223..d0a830f3 100644 --- a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx @@ -29,12 +29,12 @@ export const AnnouncementDrawerHeader = ({ - {unreadCount && parseInt(unreadCount) > 0 && ( + {unreadCount && unreadCount > 0 && ( {unreadCount} unread announcements )} - {unreadCount && parseInt(unreadCount) > 0 && ( + {unreadCount && unreadCount > 0 && ( )} diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 432bb422..34844179 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -6,8 +6,8 @@ import { Drawer } from "../common/Drawer"; import { AnnouncementItem } from "./AnnouncementItem"; import { useInfiniteAnnouncements, - useMarkAnnouncementAsReadMutation, - useMarkAllAnnouncementsAsReadMutation, + useMarkAsReadMutation, + useMarkAllAsReadMutation, } from "@/lib/query"; import { useLanguage } from "@/hooks/useLanguage"; import { AnnouncementSkeleton } from "./AnnouncementSkeleton"; @@ -18,9 +18,8 @@ import { useAnnouncementDrawer } from "@/hooks/useAnnouncementDrawer"; export const Announcements = () => { const { isOpen, close } = useAnnouncementDrawer(); const { language } = useLanguage(); - const markAsReadMutation = useMarkAnnouncementAsReadMutation(); - const markAllAsReadMutation = useMarkAllAnnouncementsAsReadMutation(); - const [markingAsReadId, setMarkingAsReadId] = useState(null); + const markAsReadMutation = useMarkAsReadMutation(); + const markAllAsReadMutation = useMarkAllAsReadMutation(); const [openedOnce, setOpenedOnce] = useState(false); const { ref: loadMoreRef, inView } = useInView(); @@ -67,26 +66,13 @@ export const Announcements = () => { }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const handleMarkAsRead = async (id: string) => { - setMarkingAsReadId(id); - - try { - await markAsReadMutation.mutateAsync({ - announcementIds: [id], - }); - } catch (error) { - console.error("Failed to mark announcement as read:", error); - } finally { - setMarkingAsReadId(null); - } + markAsReadMutation.mutate({ + announcementId: id, + }); }; const handleMarkAllAsRead = async () => { - try { - // Use the new dedicated mutation for marking all as read - await markAllAsReadMutation.mutateAsync(); - } catch (error) { - console.error("Failed to mark all announcements as read:", error); - } + markAllAsReadMutation.mutate(); }; if (isError) { @@ -131,7 +117,6 @@ export const Announcements = () => { announcement={announcement} onMarkAsRead={handleMarkAsRead} index={index} - isMarkingAsRead={markingAsReadId === announcement.id} ref={ index === processedAnnouncements.length - 1 ? loadMoreRef diff --git a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx index fa9eb3b9..67529c72 100644 --- a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx +++ b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx @@ -7,10 +7,7 @@ import { ThemeIcon, } from "@mantine/core"; import { IconAlertTriangle, IconX } from "@tabler/icons-react"; -import { - useLatestAnnouncement, - useMarkAnnouncementAsReadMutation, -} from "@/lib/query"; +import { useLatestAnnouncement, useMarkAsReadMutation } from "@/lib/query"; import { theme } from "@/theme"; import { useState } from "react"; import { useAnnouncementDrawer } from "@/hooks/useAnnouncementDrawer"; @@ -23,7 +20,7 @@ import { t } from "@lingui/core/macro"; export function TopAnnouncementBar() { const theme = useMantineTheme(); const { data: announcement, isLoading } = useLatestAnnouncement(); - const markAsReadMutation = useMarkAnnouncementAsReadMutation(); + const markAsReadMutation = useMarkAsReadMutation(); const [isClosed, setIsClosed] = useState(false); const { open } = useAnnouncementDrawer(); const { language } = useLanguage(); @@ -53,14 +50,9 @@ export function TopAnnouncementBar() { // Mark announcement as read if (announcement.id) { - try { - await markAsReadMutation.mutateAsync({ - announcementIds: [announcement.id], - }); - } catch (error) { - console.error("Failed to mark announcement as read:", error); - toast.error(t`Failed to mark announcement as read`); - } + markAsReadMutation.mutate({ + announcementId: announcement.id, + }); } }; diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index ff74cd7c..945bd979 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2306,26 +2306,23 @@ export const useInfiniteAnnouncements = ({ }); }; -export const useMarkAnnouncementAsReadMutation = () => { +export const useMarkAsReadMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ - announcementIds, + announcementId, userId, }: { - announcementIds: string[]; + announcementId: string; userId?: string; }) => { try { return await directus.request( - createItems( - "announcement_activity", - announcementIds.map((id) => ({ - announcement_activity: id, - read: true, - ...(userId ? { user_id: userId } : {}), - })) as any, - ), + createItems("announcement_activity", { + announcement_activity: announcementId, + read: true, + ...(userId ? { user_id: userId } : {}), + } as any), ); } catch (error) { console.error("Error in mutationFn:", error); @@ -2335,25 +2332,94 @@ export const useMarkAnnouncementAsReadMutation = () => { }; } }, - onSuccess: () => { - // Invalidate all announcement queries - queryClient.invalidateQueries({ queryKey: ["announcements"] }); - queryClient.invalidateQueries({ - queryKey: ["announcements", "infinite"], - }); - queryClient.invalidateQueries({ - queryKey: ["announcements", "unread"], + onMutate: async ({ announcementId }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["announcements"] }); + + // Snapshot the previous value + const previousAnnouncements = queryClient.getQueriesData({ + queryKey: ["announcements"], }); - toast.success(t`Announcement marked as read successfully!`); - }, - onError: (error) => { - console.error("Error marking announcement as read:", error); + + // Optimistically update infinite announcements + queryClient.setQueriesData( + { queryKey: ["announcements", "infinite"] }, + (old: any) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page: any) => ({ + ...page, + announcements: page.announcements.map((announcement: any) => { + if (announcement.id === announcementId) { + return { + ...announcement, + activity: [ + { + id: `temp-${announcement.id}`, + read: true, + user_id: null, + announcement_activity: announcement.id, + }, + ], + }; + } + return announcement; + }), + })), + }; + }, + ); + + // // Optimistically update latest announcement + queryClient.setQueriesData( + { queryKey: ["announcements", "latest"] }, + (old: any) => { + if (!old || old.id !== announcementId) return old; + return { + ...old, + activity: [ + { + id: `temp-${old.id}`, + read: true, + user_id: null, + announcement_activity: old.id, + }, + ], + }; + }, + ); + + // // Optimistically update unread count + queryClient.setQueriesData( + { queryKey: ["announcements", "unread"] }, + (old: number) => { + if (typeof old !== "number") return old; + return Math.max(0, old - 1); + }, + ); + + // Return a context object with the snapshotted value + return { previousAnnouncements }; + }, + onError: (err, _newAnnouncementId, context) => { + // If the mutation fails, use the context returned from onMutate to roll back + if (context?.previousAnnouncements) { + context.previousAnnouncements.forEach(([queryKey, data]) => { + queryClient.setQueriesData({ queryKey }, data); + }); + } + console.error("Error marking announcement as read:", err); toast.error(t`Failed to mark announcement as read`); }, + onSettled: () => { + // refetch after error or success to ensure cache consistency + queryClient.invalidateQueries({ queryKey: ["announcements"] }); + }, }); }; -export const useMarkAllAnnouncementsAsReadMutation = () => { +export const useMarkAllAsReadMutation = () => { const queryClient = useQueryClient(); const { data: currentUser } = useCurrentUser(); @@ -2416,22 +2482,79 @@ export const useMarkAllAnnouncementsAsReadMutation = () => { throw error; } }, - onSuccess: () => { - // Invalidate all announcement queries - queryClient.invalidateQueries({ queryKey: ["announcements"] }); - queryClient.invalidateQueries({ - queryKey: ["announcements", "infinite"], - }); - queryClient.invalidateQueries({ - queryKey: ["announcements", "unread"], + onMutate: async () => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ["announcements"] }); + + // Snapshot the previous value + const previousAnnouncements = queryClient.getQueriesData({ + queryKey: ["announcements"], }); - toast.success(t`All announcements marked as read successfully!`); - }, - onError: (error) => { - console.error("Error marking all announcements as read:", error); + // Optimistically update infinite announcements - mark all as read + queryClient.setQueriesData( + { queryKey: ["announcements", "infinite"] }, + (old: any) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page: any) => ({ + ...page, + announcements: page.announcements.map((announcement: any) => ({ + ...announcement, + activity: [ + { + id: `temp-all-${announcement.id}`, + read: true, + user_id: currentUser?.id || null, + announcement_activity: announcement.id, + }, + ], + })), + })), + }; + }, + ); + + // Optimistically update latest announcement + queryClient.setQueriesData( + { queryKey: ["announcements", "latest"] }, + (old: any) => { + if (!old) return old; + return { + ...old, + activity: [ + { + id: `temp-all-${old.id}`, + read: true, + user_id: currentUser?.id || null, + announcement_activity: old.id, + }, + ], + }; + }, + ); + + // Optimistically update unread count to 0 + queryClient.setQueriesData({ queryKey: ["announcements", "unread"] }, 0); + + // Return a context object with the snapshotted value + return { previousAnnouncements }; + }, + onError: (err, _variables, context) => { + // If the mutation fails, use the context returned from onMutate to roll back + if (context?.previousAnnouncements) { + context.previousAnnouncements.forEach(([queryKey, data]) => { + queryClient.setQueriesData({ queryKey }, data); + }); + } + console.error("Error marking all announcements as read:", err); toast.error(t`Failed to mark all announcements as read`); }, + onSettled: () => { + // refetch after error or success to ensure cache consistency + queryClient.invalidateQueries({ queryKey: ["announcements"] }); + }, }); }; From f97ea42421ea164cbf99db31a056bce21adc7597 Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 14:54:15 +0000 Subject: [PATCH 22/44] Remove unused unreadAnnouncements variable from Announcements component to streamline code and improve clarity. --- echo/frontend/src/components/announcement/Announcements.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/echo/frontend/src/components/announcement/Announcements.tsx b/echo/frontend/src/components/announcement/Announcements.tsx index 34844179..d6b3ed75 100644 --- a/echo/frontend/src/components/announcement/Announcements.tsx +++ b/echo/frontend/src/components/announcement/Announcements.tsx @@ -56,8 +56,6 @@ export const Announcements = () => { language, ); - const unreadAnnouncements = processedAnnouncements.filter((a) => !a.read); - // Load more announcements when user scrolls to bottom useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) { From 795bafd2d9bf8f46921a5085653fd27979eb9624 Mon Sep 17 00:00:00 2001 From: Usama Date: Wed, 2 Jul 2025 15:07:51 +0000 Subject: [PATCH 23/44] Enhance useAnnouncementDrawer hook to reset drawer state on page reload - Added useEffect to reset the drawer state to closed when the page is reloaded, improving user experience. - Updated useUnreadAnnouncements hook to ensure unread announcement count does not go below zero, enhancing data accuracy. --- echo/frontend/src/hooks/useAnnouncementDrawer.tsx | 6 ++++++ echo/frontend/src/lib/query.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/echo/frontend/src/hooks/useAnnouncementDrawer.tsx b/echo/frontend/src/hooks/useAnnouncementDrawer.tsx index 08eaf918..9b4f6978 100644 --- a/echo/frontend/src/hooks/useAnnouncementDrawer.tsx +++ b/echo/frontend/src/hooks/useAnnouncementDrawer.tsx @@ -1,4 +1,5 @@ import useSessionStorageState from "use-session-storage-state"; +import { useEffect } from "react"; export const useAnnouncementDrawer = () => { const [isOpen, setIsOpen] = useSessionStorageState( @@ -8,6 +9,11 @@ export const useAnnouncementDrawer = () => { }, ); + // Reset drawer state on page reload + useEffect(() => { + setIsOpen(false); + }, []); + const open = () => setIsOpen(true); const close = () => setIsOpen(false); const toggle = () => setIsOpen(!isOpen); diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts index 945bd979..295bf1ea 100644 --- a/echo/frontend/src/lib/query.ts +++ b/echo/frontend/src/lib/query.ts @@ -2610,7 +2610,7 @@ export const useUnreadAnnouncements = () => { const count = parseInt(unreadAnnouncements?.[0]?.count?.toString() ?? "0") - parseInt(activities?.[0]?.count?.toString() ?? "0"); - return count; + return Math.max(0, count); } catch (error) { console.error("Error fetching unread announcements count:", error); return 0; From b03f097c3adb323b90d9a4c6c90b4c1f3ff0e5a2 Mon Sep 17 00:00:00 2001 From: Usama Date: Thu, 3 Jul 2025 05:49:45 +0000 Subject: [PATCH 24/44] Update TopAnnouncementBar to apply line clamping on title Markdown for improved text display --- .../frontend/src/components/announcement/TopAnnouncementBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx index 67529c72..20b85fd8 100644 --- a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx +++ b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx @@ -75,7 +75,7 @@ export function TopAnnouncementBar() { > - + Date: Thu, 3 Jul 2025 05:56:52 +0000 Subject: [PATCH 25/44] Add focus outline to close button in AnnouncementDrawerHeader for improved accessibility --- .../src/components/announcement/AnnouncementDrawerHeader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx index d0a830f3..a0b83bd9 100644 --- a/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx +++ b/echo/frontend/src/components/announcement/AnnouncementDrawerHeader.tsx @@ -24,6 +24,7 @@ export const AnnouncementDrawerHeader = ({ variant="transparent" onClick={onClose} aria-label="Close drawer" + className="focus:outline-none" > From 9437d1b2a17c3fa47e6180348c648b362f463be3 Mon Sep 17 00:00:00 2001 From: Usama Date: Thu, 3 Jul 2025 08:14:48 +0000 Subject: [PATCH 26/44] Enhance layout responsiveness by introducing dynamic height and padding variables in Tailwind configuration - Added custom height and padding variables to Tailwind config for base and project layouts. - Updated TopAnnouncementBar to adjust layout height dynamically based on announcement state. - Refactored BaseLayout and ProjectLayout components to utilize new Tailwind variables for improved layout consistency. --- .../announcement/TopAnnouncementBar.tsx | 18 +++++++++++++++++- .../src/components/layout/BaseLayout.tsx | 4 ++-- .../src/components/layout/ProjectLayout.tsx | 2 +- echo/frontend/tailwind.config.js | 8 ++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx index 20b85fd8..293141b4 100644 --- a/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx +++ b/echo/frontend/src/components/announcement/TopAnnouncementBar.tsx @@ -9,7 +9,7 @@ import { import { IconAlertTriangle, IconX } from "@tabler/icons-react"; import { useLatestAnnouncement, useMarkAsReadMutation } from "@/lib/query"; import { theme } from "@/theme"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useAnnouncementDrawer } from "@/hooks/useAnnouncementDrawer"; import { useLanguage } from "@/hooks/useLanguage"; import { Markdown } from "@/components/common/Markdown"; @@ -31,6 +31,22 @@ export function TopAnnouncementBar() { (activity) => activity.read === true, ); + useEffect(() => { + const shouldUseDefaultHeight = + isLoading || + !announcement || + announcement.level !== "urgent" || + isClosed || + isRead; + + const height = shouldUseDefaultHeight ? "60px" : "112px"; + const root = document.documentElement.style; + + root.setProperty("--base-layout-height", `calc(100% - ${height})`, "important"); + root.setProperty("--base-layout-padding", height, "important"); + root.setProperty("--project-layout-height", `calc(100vh - ${height})`, "important"); + }, [isLoading, announcement, isClosed, isRead]); + // Only show if we have an urgent announcement, it's not closed, and it's not read if ( isLoading || diff --git a/echo/frontend/src/components/layout/BaseLayout.tsx b/echo/frontend/src/components/layout/BaseLayout.tsx index 03eafaf8..eaa80dba 100644 --- a/echo/frontend/src/components/layout/BaseLayout.tsx +++ b/echo/frontend/src/components/layout/BaseLayout.tsx @@ -8,12 +8,12 @@ import { ErrorBoundary } from "../error/ErrorBoundary"; export const BaseLayout = ({ children }: PropsWithChildren) => { return ( - +
-
+
{children}
diff --git a/echo/frontend/src/components/layout/ProjectLayout.tsx b/echo/frontend/src/components/layout/ProjectLayout.tsx index 34e6de53..ae9900ac 100644 --- a/echo/frontend/src/components/layout/ProjectLayout.tsx +++ b/echo/frontend/src/components/layout/ProjectLayout.tsx @@ -17,7 +17,7 @@ export const ProjectLayout = () => {