@@ -12,8 +12,13 @@ import {
1212 ThemeIcon ,
1313 UnstyledButton ,
1414} from "@mantine/core" ;
15- import { CaretDown , CaretUp , Sparkle } from "@phosphor-icons/react" ;
16- import { useEffect , useRef , useState } from "react" ;
15+ import {
16+ CaretDown ,
17+ CaretUp ,
18+ CheckCircle ,
19+ Sparkle ,
20+ } from "@phosphor-icons/react" ;
21+ import { useEffect , useMemo , useState } from "react" ;
1722import { useInView } from "react-intersection-observer" ;
1823import { useAnnouncementDrawer } from "@/components/announcement/hooks" ;
1924import {
@@ -33,16 +38,20 @@ import { WhatsNewItem } from "./WhatsNewItem";
3338import {
3439 useInfiniteAnnouncements ,
3540 useMarkAllAsReadMutation ,
41+ useMarkAsReadMutation ,
42+ useMarkAsUnreadMutation ,
3643 useWhatsNewAnnouncements ,
3744} from "./hooks" ;
3845
3946export const Announcements = ( ) => {
4047 const { isOpen, close } = useAnnouncementDrawer ( ) ;
4148 const { language } = useLanguage ( ) ;
49+ const markAsReadMutation = useMarkAsReadMutation ( ) ;
50+ const markAsUnreadMutation = useMarkAsUnreadMutation ( ) ;
4251 const markAllAsReadMutation = useMarkAllAsReadMutation ( ) ;
4352 const [ openedOnce , setOpenedOnce ] = useState ( false ) ;
53+ const [ readExpanded , setReadExpanded ] = useState ( false ) ;
4454 const [ whatsNewExpanded , setWhatsNewExpanded ] = useState ( false ) ;
45- const autoReadTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
4655
4756 const { ref : loadMoreRef , inView } = useInView ( ) ;
4857
@@ -58,23 +67,6 @@ export const Announcements = () => {
5867 }
5968 } , [ isOpen , openedOnce ] ) ;
6069
61- // Auto-mark all as read after 1 second when drawer opens
62- // biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on isOpen changes, mutate ref is stable
63- useEffect ( ( ) => {
64- if ( isOpen ) {
65- autoReadTimerRef . current = setTimeout ( ( ) => {
66- markAllAsReadMutation . mutate ( ) ;
67- } , 1000 ) ;
68- }
69-
70- return ( ) => {
71- if ( autoReadTimerRef . current ) {
72- clearTimeout ( autoReadTimerRef . current ) ;
73- autoReadTimerRef . current = null ;
74- }
75- } ;
76- } , [ isOpen ] ) ;
77-
7870 const {
7971 data : announcementsData ,
8072 fetchNextPage,
@@ -106,22 +98,46 @@ export const Announcements = () => {
10698 language ,
10799 ) ;
108100
109- // Only show unread announcements (read ones are hidden)
110- const unreadAnnouncements = processedAnnouncements . filter ( ( a ) => ! a . read ) ;
111-
112- // Process "What's new" announcements
113- const whatsNewAnnouncements = useWhatsNewProcessed (
114- whatsNewData ?? [ ] ,
115- language ,
101+ // Split into unread and read
102+ const unreadAnnouncements = useMemo (
103+ ( ) => processedAnnouncements . filter ( ( a ) => ! a . read ) ,
104+ [ processedAnnouncements ] ,
105+ ) ;
106+ const readAnnouncements = useMemo (
107+ ( ) => processedAnnouncements . filter ( ( a ) => a . read ) ,
108+ [ processedAnnouncements ] ,
116109 ) ;
117110
111+ // Auto-expand read section when there are no unread items
112+ // biome-ignore lint/correctness/useExhaustiveDependencies: only react to unread/read count changes
113+ useEffect ( ( ) => {
114+ if ( unreadAnnouncements . length === 0 && readAnnouncements . length > 0 ) {
115+ setReadExpanded ( true ) ;
116+ }
117+ } , [ unreadAnnouncements . length , readAnnouncements . length ] ) ;
118+
119+ // Process "What's new" announcements, excluding those already in the main list
120+ const whatsNewRaw = useWhatsNewProcessed ( whatsNewData ?? [ ] , language ) ;
121+ const whatsNewAnnouncements = useMemo ( ( ) => {
122+ const mainIds = new Set ( processedAnnouncements . map ( ( a ) => a . id ) ) ;
123+ return whatsNewRaw . filter ( ( a ) => ! mainIds . has ( a . id ) ) ;
124+ } , [ whatsNewRaw , processedAnnouncements ] ) ;
125+
118126 // Load more announcements when user scrolls to bottom
119127 useEffect ( ( ) => {
120128 if ( inView && hasNextPage && ! isFetchingNextPage ) {
121129 fetchNextPage ( ) ;
122130 }
123131 } , [ inView , hasNextPage , isFetchingNextPage , fetchNextPage ] ) ;
124132
133+ const handleMarkAsRead = async ( id : string ) => {
134+ markAsReadMutation . mutate ( { announcementId : id } ) ;
135+ } ;
136+
137+ const handleMarkAsUnread = async ( id : string , activityIds : string [ ] ) => {
138+ markAsUnreadMutation . mutate ( { announcementId : id , activityIds } ) ;
139+ } ;
140+
125141 const handleMarkAllAsRead = async ( ) => {
126142 markAllAsReadMutation . mutate ( ) ;
127143 } ;
@@ -166,7 +182,7 @@ export const Announcements = () => {
166182 />
167183 ) : isLoading ? (
168184 < AnnouncementSkeleton />
169- ) : unreadAnnouncements . length === 0 &&
185+ ) : processedAnnouncements . length === 0 &&
170186 whatsNewAnnouncements . length === 0 ? (
171187 < Box p = "md" { ...testId ( "announcement-empty-state" ) } >
172188 < Text c = "dimmed" ta = "center" >
@@ -180,12 +196,9 @@ export const Announcements = () => {
180196 < AnnouncementItem
181197 key = { announcement . id }
182198 announcement = { announcement }
199+ onMarkAsRead = { handleMarkAsRead }
200+ onMarkAsUnread = { handleMarkAsUnread }
183201 index = { index }
184- ref = {
185- index === unreadAnnouncements . length - 1
186- ? loadMoreRef
187- : undefined
188- }
189202 />
190203 ) ) }
191204
@@ -195,7 +208,61 @@ export const Announcements = () => {
195208 </ Center >
196209 ) }
197210
198- { /* Release notes under "View earlier" */ }
211+ { /* Earlier (read) section */ }
212+ { readAnnouncements . length > 0 && (
213+ < >
214+ < Divider
215+ my = "md"
216+ mx = "md"
217+ label = {
218+ < UnstyledButton
219+ onClick = { ( ) =>
220+ setReadExpanded ( ! readExpanded )
221+ }
222+ >
223+ < Group gap = "xs" align = "center" >
224+ < CheckCircle
225+ size = { 16 }
226+ weight = "fill"
227+ color = "gray"
228+ />
229+ < Text
230+ size = "sm"
231+ fw = { 500 }
232+ c = "dimmed"
233+ >
234+ < Trans > Earlier</ Trans >
235+ </ Text >
236+ { readExpanded ? (
237+ < CaretUp size = { 14 } color = "gray" />
238+ ) : (
239+ < CaretDown size = { 14 } color = "gray" />
240+ ) }
241+ </ Group >
242+ </ UnstyledButton >
243+ }
244+ labelPosition = "left"
245+ />
246+
247+ < Collapse in = { readExpanded } >
248+ < Stack gap = "0" >
249+ { readAnnouncements . map (
250+ ( announcement , index ) => (
251+ < AnnouncementItem
252+ key = { announcement . id }
253+ announcement = { announcement }
254+ onMarkAsRead = { handleMarkAsRead }
255+ onMarkAsUnread = { handleMarkAsUnread }
256+ index = { index }
257+ />
258+ ) ,
259+ ) }
260+ </ Stack >
261+ </ Collapse >
262+ </ >
263+ ) }
264+
265+ { /* Release notes */ }
199266 { whatsNewAnnouncements . length > 0 && (
200267 < >
201268 < Divider
@@ -243,6 +310,9 @@ export const Announcements = () => {
243310 </ Collapse >
244311 </ >
245312 ) }
313+
314+ { /* Infinite scroll sentinel */ }
315+ < div ref = { loadMoreRef } />
246316 </ >
247317 ) }
248318 </ Stack >
0 commit comments