11// React
2- import React , { useCallback , useContext , useEffect , useState , useRef } from 'react' ;
3- import { useSearchParams } from "react-router-dom" ;
2+ import React , { useCallback , useContext , useEffect , useRef , useState } from 'react' ;
3+
44// Redux
55import { useSelector , useDispatch } from 'react-redux'
66import { memoize } from 'proxy-memoize'
77import { updateBulletins } from '../slices/cmsSlice' ;
88
99// External imports
1010import Container from 'react-bootstrap/Container' ;
11-
12- // Styling
13- import './BulletinsListPage.scss' ;
11+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' ;
12+ import { faArrowUp , faArrowDown } from '@fortawesome/pro-regular-svg-icons' ;
1413
1514// Internal imports
1615import { CMSContext } from '../App' ;
@@ -24,10 +23,11 @@ import Footer from '../Footer';
2423import PageHeader from '../PageHeader' ;
2524import PollingComponent from "../Components/shared/PollingComponent" ;
2625
26+ // Styling
27+ import './BulletinsListPage.scss' ;
28+
2729export default function BulletinsListPage ( ) {
2830 document . title = 'DriveBC - Bulletins' ;
29- // const [searchParams] = useSearchParams();
30- // const isPreview = searchParams.get("preview") === "true";
3131
3232 // Context
3333 const { cmsContext, setCMSContext } = useContext ( CMSContext ) ;
@@ -38,61 +38,170 @@ export default function BulletinsListPage() {
3838 bulletins : state . cms . bulletins . list ,
3939 } ) ) ) ) ;
4040
41+ // Refs
42+ const isInitialLoad = useRef ( true ) ;
43+ const bulletinRefs = useRef ( { } ) ;
44+ const viewedHighlightedBulletins = useRef ( new Set ( ) ) ;
45+ const bulletinsInViewport = useRef ( { } ) ;
46+ const trackedBulletinsRef = useRef ( { } ) ;
47+ const dismissedHighlightsRef = useRef (
48+ new Set ( JSON . parse ( sessionStorage . getItem ( 'dismissedBulletinHighlights' ) || '[]' ) )
49+ ) ;
50+ const highlightedBeforeRefreshRef = useRef (
51+ new Set ( JSON . parse ( sessionStorage . getItem ( 'highlightedBulletins' ) || '[]' ) )
52+ ) ;
53+
4154 // States
55+ const [ showLoader , setShowLoader ] = useState ( true ) ;
56+ const [ trackedBulletins , setTrackedBulletins ] = useState ( { } ) ;
57+ const [ updateCounts , setUpdateCounts ] = useState ( { above : 0 , below : 0 } ) ;
4258 const [ showNetworkError , setShowNetworkError ] = useState ( false ) ;
4359 const [ showServerError , setShowServerError ] = useState ( false ) ;
44- const [ showLoader , setShowLoader ] = useState ( true ) ;
4560
4661 // Error handling
4762 const displayError = ( error ) => {
4863 if ( error instanceof ServerError ) {
4964 setShowServerError ( true ) ;
50-
5165 } else if ( error instanceof NetworkError ) {
5266 setShowNetworkError ( true ) ;
5367 }
5468 }
5569
56- // Refs
57- const isInitialMount = useRef ( true ) ;
70+ const dismissHighlight = ( bulletinId ) => {
71+ dismissedHighlightsRef . current . add ( String ( bulletinId ) ) ;
72+ sessionStorage . setItem ( 'dismissedBulletinHighlights' , JSON . stringify ( [ ...dismissedHighlightsRef . current ] ) ) ;
73+
74+ highlightedBeforeRefreshRef . current . delete ( String ( bulletinId ) ) ;
75+ sessionStorage . setItem ( 'highlightedBulletins' , JSON . stringify ( [ ...highlightedBeforeRefreshRef . current ] ) ) ;
76+ } ;
5877
5978 // Data loading
6079 const loadBulletins = async ( ) => {
61- // Skip loading if the bulletins are already loaded on launch
62- if ( bulletins && isInitialMount . current ) {
63- isInitialMount . current = false ;
64- setShowLoader ( false ) ;
65- return ;
66- }
67-
68- let bulletinsData = bulletins ;
69- if ( ! bulletinsData ) {
70- bulletinsData = await getBulletins ( ) . catch ( ( error ) => displayError ( error ) ) ;
80+ const bulletinsData = await getBulletins ( ) . catch ( ( error ) => displayError ( error ) ) ;
81+ if ( ! bulletinsData ) return ;
82+
83+ const trackedDict = bulletinsData . reduce ( ( acc , bulletin ) => {
84+ const tracked = trackedBulletinsRef . current [ bulletin . id ] ?? null ;
85+ const isDismissed = dismissedHighlightsRef . current . has ( String ( bulletin . id ) ) ;
86+ const wasHighlighted = highlightedBeforeRefreshRef . current . has ( String ( bulletin . id ) ) ;
87+ const revisionChanged = tracked && bulletin . live_revision !== tracked . live_revision ;
88+
89+ if ( revisionChanged ) {
90+ dismissedHighlightsRef . current . delete ( String ( bulletin . id ) ) ;
91+ sessionStorage . setItem ( 'dismissedBulletinHighlights' , JSON . stringify ( [ ...dismissedHighlightsRef . current ] ) ) ;
92+ }
7193
72- dispatch ( updateBulletins ( {
73- list : bulletinsData ,
74- timeStamp : new Date ( ) . getTime ( )
75- } ) ) ;
76- }
94+ acc [ bulletin . id ] = {
95+ highlight : isDismissed
96+ ? false
97+ : tracked
98+ ? revisionChanged || tracked . highlight
99+ : wasHighlighted || ! isInitialLoad . current ,
100+ live_revision : bulletin . live_revision ,
101+ } ;
102+ return acc ;
103+ } , { } ) ;
104+
105+ // Persist highlighted IDs to sessionStorage
106+ const highlightedIds = Object . entries ( trackedDict )
107+ . filter ( ( [ , v ] ) => v . highlight )
108+ . map ( ( [ id ] ) => id ) ;
109+ sessionStorage . setItem ( 'highlightedBulletins' , JSON . stringify ( highlightedIds ) ) ;
110+ highlightedBeforeRefreshRef . current = new Set ( highlightedIds ) ;
111+
112+ // Merge into ref instead of overwriting
113+ trackedBulletinsRef . current = {
114+ ...trackedBulletinsRef . current ,
115+ ...trackedDict ,
116+ } ;
117+ setTrackedBulletins ( { ...trackedBulletinsRef . current } ) ;
118+
119+ dispatch ( updateBulletins ( {
120+ list : bulletinsData ,
121+ timeStamp : new Date ( ) . getTime ( )
122+ } ) ) ;
77123
78- isInitialMount . current = false ;
79124 markBulletinsAsRead ( bulletinsData , cmsContext , setCMSContext ) ;
125+
126+ isInitialLoad . current = false ;
80127 setShowLoader ( false ) ;
81128 }
82129
83130 useEffect ( ( ) => {
84131 loadBulletins ( ) ;
85- } , [ showLoader ] ) ;
132+ } , [ ] ) ;
133+
134+ // Intersection observer
135+ useEffect ( ( ) => {
136+ const observer = new IntersectionObserver (
137+ ( entries ) => {
138+ entries . forEach ( ( entry ) => {
139+ const bulletinId = entry . target . getAttribute ( 'data-key' ) ;
140+ const isHighlighted = trackedBulletins [ bulletinId ] ?. highlight ;
141+
142+ if ( entry . isIntersecting ) {
143+ bulletinsInViewport . current [ bulletinId ] = null ;
144+ if ( isHighlighted && ! viewedHighlightedBulletins . current . has ( bulletinId ) ) {
145+ viewedHighlightedBulletins . current . add ( bulletinId ) ;
146+ }
147+ } else {
148+ delete bulletinsInViewport . current [ bulletinId ] ;
149+ }
150+ } ) ;
151+
152+ // Count highlighted items above/below viewport
153+ const counts = { above : 0 , below : 0 } ;
154+ Object . entries ( bulletinRefs . current ) . forEach ( ( [ id , ref ] ) => {
155+ const isHighlighted = trackedBulletins [ id ] ?. highlight ;
156+ if ( ! ref || ! isHighlighted || viewedHighlightedBulletins . current . has ( id ) ) return ;
157+ const top = ref . getBoundingClientRect ( ) . top ;
158+ top < 0 ? counts . above ++ : counts . below ++ ;
159+ } ) ;
160+ setUpdateCounts ( counts ) ;
161+ } ,
162+ { rootMargin : '-58px 0px 0px 0px' , threshold : 1 }
163+ ) ;
164+
165+ setTimeout ( ( ) => {
166+ Object . values ( bulletinRefs . current ) . forEach ( ( ref ) => {
167+ if ( ref ) observer . observe ( ref ) ;
168+ } ) ;
169+ } , 0 ) ;
170+
171+ return ( ) => observer . disconnect ( ) ;
172+ } , [ bulletins , trackedBulletins ] ) ;
173+
174+ const scrollToNextHighlightedHandler = ( direction ) => {
175+ const offset = 58 + 48 ;
176+ const sortedRefs = Object . entries ( bulletinRefs . current )
177+ . filter ( ( [ id ] ) => trackedBulletins [ id ] ?. highlight && ! viewedHighlightedBulletins . current . has ( id ) )
178+ . map ( ( [ id , ref ] ) => ( {
179+ id,
180+ top : Math . floor ( ref . getBoundingClientRect ( ) . top + window . scrollY - offset )
181+ } ) )
182+ . sort ( ( a , b ) => a . top - b . top ) ;
183+
184+ const currentScroll = Math . floor ( window . scrollY ) ;
185+ let nextTop ;
186+
187+ if ( direction === 'above' ) {
188+ nextTop = sortedRefs . reverse ( ) . find ( r => r . top < currentScroll ) ?. top ;
189+ } else {
190+ nextTop = sortedRefs . find ( r => r . top > currentScroll ) ?. top ;
191+ }
192+
193+ if ( nextTop !== undefined ) {
194+ document . querySelector ( '#main' ) ?. scrollTo ( { top : nextTop , behavior : 'smooth' } ) ;
195+ }
196+ } ;
86197
87198 const isBulletinsEmpty = bulletins ?. length === 0 ;
88199
89200 return (
90201 < div className = 'bulletins-page cms-page' >
91- < PollingComponent runnable = { ( ) => setShowLoader ( true ) } interval = { 30000 } />
92- { showNetworkError &&
93- < NetworkErrorPopup />
94- }
202+ < PollingComponent runnable = { loadBulletins } interval = { 5000 } />
95203
204+ { showNetworkError && < NetworkErrorPopup /> }
96205 { ! showNetworkError && showServerError &&
97206 < ServerErrorPopup setShowServerError = { setShowServerError } />
98207 }
@@ -104,13 +213,29 @@ export default function BulletinsListPage() {
104213
105214 < Container >
106215 { isBulletinsEmpty ?
107- < EmptyBulletin /> :
108-
109- < BulletinsList bulletins = { bulletins } showLoader = { showLoader } />
216+ < EmptyBulletin /> :
217+ < BulletinsList
218+ bulletins = { bulletins }
219+ showLoader = { showLoader }
220+ trackedBulletins = { trackedBulletins }
221+ bulletinRefs = { bulletinRefs }
222+ dismissHighlight = { dismissHighlight }
223+ />
110224 }
111225 </ Container >
112226
227+ { updateCounts . above > 0 &&
228+ < button className = "update-count-pill top" onClick = { ( ) => scrollToNextHighlightedHandler ( 'above' ) } >
229+ < FontAwesomeIcon icon = { faArrowUp } /> { updateCounts . above } update{ updateCounts . above !== 1 ? 's' : '' } available
230+ </ button >
231+ }
232+ { updateCounts . below > 0 &&
233+ < button className = "update-count-pill bottom" onClick = { ( ) => scrollToNextHighlightedHandler ( 'below' ) } >
234+ < FontAwesomeIcon icon = { faArrowDown } /> { updateCounts . below } update{ updateCounts . below !== 1 ? 's' : '' } available
235+ </ button >
236+ }
237+
113238 < Footer />
114239 </ div >
115240 ) ;
116- }
241+ }
0 commit comments