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
51 changes: 39 additions & 12 deletions echo/frontend/src/components/announcement/AnnouncementItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useFormatDate } from "./utils/dateUtils";

type Announcement = {
id: string;
activityIds: string[];
title: string;
message: string;
created_at: string | Date | null | undefined;
Expand All @@ -31,13 +32,15 @@ type Announcement = {

interface AnnouncementItemProps {
announcement: Announcement;
onMarkAsRead: (id: string) => void;
onMarkAsUnread: (id: string, activityIds: string[]) => void;
index: number;
}

export const AnnouncementItem = forwardRef<
HTMLDivElement,
AnnouncementItemProps
>(({ announcement, index }, ref) => {
>(({ announcement, onMarkAsRead, onMarkAsUnread, index }, ref) => {
const theme = useMantineTheme();
const [showMore, setShowMore] = useState(false);
const [showReadMoreButton, setShowReadMoreButton] = useState(false);
Expand All @@ -52,14 +55,12 @@ export const AnnouncementItem = forwardRef<
}
}, []);

const isRead = !!announcement.read;

return (
<Box
ref={ref}
className={`group border-b border-gray-100 p-4 transition-all duration-200 hover:bg-blue-50 ${index === 0 ? "border-t-0" : ""} ${
!announcement.read
? "border-l-4 border-l-blue-500"
: "border-l-4 border-l-gray-50/50 bg-gray-50/50"
}`}
className={`group border-b border-gray-100 p-4 transition-all duration-200 ${!isRead ? "hover:bg-blue-50" : ""} ${index === 0 ? "border-t-0" : ""} border-l-4 ${isRead ? "border-l-gray-50/50 bg-gray-50/50" : "border-l-blue-500"}`}
{...testId(`announcement-item-${announcement.id}`)}
>
<Stack gap="xs">
Expand All @@ -79,7 +80,7 @@ export const AnnouncementItem = forwardRef<
<Stack gap="xs" style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
<Text size="sm" fw={isRead ? 400 : 500} c={isRead ? "dimmed" : undefined}>
{announcement.title}
</Text>
</div>
Expand All @@ -89,7 +90,7 @@ export const AnnouncementItem = forwardRef<
{formatDate(announcement.created_at)}
</Text>

{!announcement.read && (
{!isRead && (
<div
style={{
backgroundColor: theme.colors.blue[6],
Expand All @@ -110,8 +111,8 @@ export const AnnouncementItem = forwardRef<
/>
</Text>

{showReadMoreButton && (
<Group justify="flex-start">
<Group justify="space-between" align="center">
{showReadMoreButton && (
<Button
variant="transparent"
color="gray"
Expand All @@ -133,8 +134,34 @@ export const AnnouncementItem = forwardRef<
</Group>
)}
</Button>
</Group>
)}
)}

{isRead ? (
<Button
variant="transparent"
size="xs"
color="gray"
className="hover:underline"
ml="auto"
onClick={() => onMarkAsUnread(announcement.id, announcement.activityIds)}
{...testId("announcement-mark-as-unread-button")}
>
<Trans>Mark as unread</Trans>
</Button>
) : (
<Button
variant="transparent"
size="xs"
color="gray"
className="hover:underline"
ml="auto"
onClick={() => onMarkAsRead(announcement.id)}
{...testId("announcement-mark-as-read-button")}
>
<Trans>Mark as read</Trans>
</Button>
)}
</Group>
</Stack>
</Group>
</Stack>
Expand Down
138 changes: 104 additions & 34 deletions echo/frontend/src/components/announcement/Announcements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import {
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { CaretDown, CaretUp, Sparkle } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import {
CaretDown,
CaretUp,
CheckCircle,
Sparkle,
} from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { useAnnouncementDrawer } from "@/components/announcement/hooks";
import {
Expand All @@ -33,16 +38,20 @@ import { WhatsNewItem } from "./WhatsNewItem";
import {
useInfiniteAnnouncements,
useMarkAllAsReadMutation,
useMarkAsReadMutation,
useMarkAsUnreadMutation,
useWhatsNewAnnouncements,
} from "./hooks";

export const Announcements = () => {
const { isOpen, close } = useAnnouncementDrawer();
const { language } = useLanguage();
const markAsReadMutation = useMarkAsReadMutation();
const markAsUnreadMutation = useMarkAsUnreadMutation();
const markAllAsReadMutation = useMarkAllAsReadMutation();
const [openedOnce, setOpenedOnce] = useState(false);
const [readExpanded, setReadExpanded] = useState(false);
const [whatsNewExpanded, setWhatsNewExpanded] = useState(false);
const autoReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const { ref: loadMoreRef, inView } = useInView();

Expand All @@ -58,23 +67,6 @@ export const Announcements = () => {
}
}, [isOpen, openedOnce]);

// Auto-mark all as read after 1 second when drawer opens
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on isOpen changes, mutate ref is stable
useEffect(() => {
if (isOpen) {
autoReadTimerRef.current = setTimeout(() => {
markAllAsReadMutation.mutate();
}, 1000);
}

return () => {
if (autoReadTimerRef.current) {
clearTimeout(autoReadTimerRef.current);
autoReadTimerRef.current = null;
}
};
}, [isOpen]);

const {
data: announcementsData,
fetchNextPage,
Expand Down Expand Up @@ -106,22 +98,46 @@ export const Announcements = () => {
language,
);

// Only show unread announcements (read ones are hidden)
const unreadAnnouncements = processedAnnouncements.filter((a) => !a.read);

// Process "What's new" announcements
const whatsNewAnnouncements = useWhatsNewProcessed(
whatsNewData ?? [],
language,
// Split into unread and read
const unreadAnnouncements = useMemo(
() => processedAnnouncements.filter((a) => !a.read),
[processedAnnouncements],
);
const readAnnouncements = useMemo(
() => processedAnnouncements.filter((a) => a.read),
[processedAnnouncements],
);

// Auto-expand read section when there are no unread items
// biome-ignore lint/correctness/useExhaustiveDependencies: only react to unread/read count changes
useEffect(() => {
if (unreadAnnouncements.length === 0 && readAnnouncements.length > 0) {
setReadExpanded(true);
}
}, [unreadAnnouncements.length, readAnnouncements.length]);

// Process "What's new" announcements, excluding those already in the main list
const whatsNewRaw = useWhatsNewProcessed(whatsNewData ?? [], language);
const whatsNewAnnouncements = useMemo(() => {
const mainIds = new Set(processedAnnouncements.map((a) => a.id));
return whatsNewRaw.filter((a) => !mainIds.has(a.id));
}, [whatsNewRaw, processedAnnouncements]);

// Load more announcements when user scrolls to bottom
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

const handleMarkAsRead = async (id: string) => {
markAsReadMutation.mutate({ announcementId: id });
};

const handleMarkAsUnread = async (id: string, activityIds: string[]) => {
markAsUnreadMutation.mutate({ announcementId: id, activityIds });
};

const handleMarkAllAsRead = async () => {
markAllAsReadMutation.mutate();
};
Expand Down Expand Up @@ -166,7 +182,7 @@ export const Announcements = () => {
/>
) : isLoading ? (
<AnnouncementSkeleton />
) : unreadAnnouncements.length === 0 &&
) : processedAnnouncements.length === 0 &&
whatsNewAnnouncements.length === 0 ? (
<Box p="md" {...testId("announcement-empty-state")}>
<Text c="dimmed" ta="center">
Expand All @@ -180,12 +196,9 @@ export const Announcements = () => {
<AnnouncementItem
key={announcement.id}
announcement={announcement}
onMarkAsRead={handleMarkAsRead}
onMarkAsUnread={handleMarkAsUnread}
index={index}
ref={
index === unreadAnnouncements.length - 1
? loadMoreRef
: undefined
}
/>
))}

Expand All @@ -195,7 +208,61 @@ export const Announcements = () => {
</Center>
)}

{/* Release notes under "View earlier" */}
{/* Earlier (read) section */}
{readAnnouncements.length > 0 && (
<>
<Divider
my="md"
mx="md"
label={
<UnstyledButton
onClick={() =>
setReadExpanded(!readExpanded)
}
>
<Group gap="xs" align="center">
<CheckCircle
size={16}
weight="fill"
color="gray"
/>
<Text
size="sm"
fw={500}
c="dimmed"
>
<Trans>Earlier</Trans>
</Text>
{readExpanded ? (
<CaretUp size={14} color="gray" />
) : (
<CaretDown size={14} color="gray" />
)}
</Group>
</UnstyledButton>
}
labelPosition="left"
/>

<Collapse in={readExpanded}>
<Stack gap="0">
{readAnnouncements.map(
(announcement, index) => (
<AnnouncementItem
key={announcement.id}
announcement={announcement}
onMarkAsRead={handleMarkAsRead}
onMarkAsUnread={handleMarkAsUnread}
index={index}
/>
),
)}
</Stack>
</Collapse>
</>
)}

{/* Release notes */}
{whatsNewAnnouncements.length > 0 && (
<>
<Divider
Expand Down Expand Up @@ -243,6 +310,9 @@ export const Announcements = () => {
</Collapse>
</>
)}

{/* Infinite scroll sentinel */}
<div ref={loadMoreRef} />
</>
)}
</Stack>
Expand Down
Loading
Loading