@@ -486,7 +486,6 @@ export function MeetingDetail() {
486486 // reset to null so subsequent live-appended parts stay visible.
487487 const TRANSCRIPT_PAGE_SIZE = 200 ;
488488 const MAX_WINDOW_SIZE = 400 ; // hard DOM cap — slides instead of growing
489- const SCROLL_LOAD_THRESHOLD = 120 ; // px from top OR bottom
490489 const [ topAnchorChunkId , setTopAnchorChunkId ] = useState < string | null > ( null ) ;
491490 const [ bottomAnchorChunkId , setBottomAnchorChunkId ] = useState < string | null > ( null ) ;
492491
@@ -579,64 +578,105 @@ export function MeetingDetail() {
579578 el . scrollTop += newTop - anchor . viewportTop ;
580579 } ;
581580
582- // Single scroll handler — fires up-load OR down-load depending on which
583- // edge the user is near. Throttled via inflight ref so a momentum bounce
584- // doesn't trigger 2 loads in a row.
581+ // v1.2.14: load-more via IntersectionObserver (Facebook-feed pattern).
582+ // Replaces the scrollTop-threshold heuristic which kept firing as the user
583+ // sat near the edge — observer fires ONLY on visibility transitions, plus
584+ // a 600ms cooldown safety net so a window re-render that keeps the
585+ // sentinel partially visible can't burst into a load loop.
585586 const loadingMoreRef = useRef ( false ) ;
586- const handleTranscriptScroll = useCallback ( ( ) => {
587+ const topSentinelRef = useRef < HTMLDivElement > ( null ) ;
588+ const bottomSentinelRef = useRef < HTMLDivElement > ( null ) ;
589+
590+ const loadOlder = useCallback ( ( ) => {
587591 if ( loadingMoreRef . current ) return ;
588592 const el = transcriptRef . current ;
589- if ( ! el ) return ;
593+ if ( ! el || ! hasOlderParts ) return ;
594+ loadingMoreRef . current = true ;
590595
591- const nearTop = el . scrollTop <= SCROLL_LOAD_THRESHOLD ;
592- const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight ;
593- const nearBottom = distanceFromBottom <= SCROLL_LOAD_THRESHOLD ;
596+ const anchor = captureScrollAnchor ( el ) ;
597+ const total = transcriptParts . length ;
598+ // Slide window backward by one page; cap size at MAX_WINDOW_SIZE.
599+ // Keep tail open when newEnd === total so live appends still flow.
600+ const newStart = Math . max ( 0 , visibleStartIdx - TRANSCRIPT_PAGE_SIZE ) ;
601+ const newEnd = Math . min ( total , newStart + MAX_WINDOW_SIZE ) ;
602+ setTopAnchorChunkId ( newStart > 0 ? ( transcriptParts [ newStart ] ?. chunkId || null ) : null ) ;
603+ setBottomAnchorChunkId (
604+ newEnd < total ? ( transcriptParts [ newEnd - 1 ] ?. chunkId || null ) : null ,
605+ ) ;
594606
595- const wantsUpLoad = nearTop && hasOlderParts ;
596- const wantsDownLoad = nearBottom && hasNewerParts ;
597- if ( ! wantsUpLoad && ! wantsDownLoad ) return ;
607+ requestAnimationFrame ( ( ) => {
608+ const elNow = transcriptRef . current ;
609+ if ( elNow ) restoreScrollAnchor ( elNow , anchor ) ;
610+ // Cooldown — prevents instant re-trigger if the sentinel happens
611+ // to remain partially visible after the slide. User must scroll
612+ // away + back to load the next page.
613+ setTimeout ( ( ) => { loadingMoreRef . current = false ; } , 600 ) ;
614+ } ) ;
615+ } , [ hasOlderParts , visibleStartIdx , transcriptParts ] ) ;
598616
617+ const loadNewer = useCallback ( ( ) => {
618+ if ( loadingMoreRef . current ) return ;
619+ const el = transcriptRef . current ;
620+ if ( ! el || ! hasNewerParts ) return ;
599621 loadingMoreRef . current = true ;
622+
600623 const anchor = captureScrollAnchor ( el ) ;
601624 const total = transcriptParts . length ;
602-
603- if ( wantsUpLoad ) {
604- // Slide window backward by one page; cap size at MAX_WINDOW_SIZE
605- // by trimming the same number of parts off the bottom. Keep
606- // tail open when newEnd === total so live appends still flow
607- // naturally — visibleStartIdx's hard cap (start ≥ end - MAX)
608- // prevents unbounded growth even in tail-open mode.
609- const newStart = Math . max ( 0 , visibleStartIdx - TRANSCRIPT_PAGE_SIZE ) ;
610- const newEnd = Math . min ( total , newStart + MAX_WINDOW_SIZE ) ;
611- setTopAnchorChunkId ( newStart > 0 ? ( transcriptParts [ newStart ] ?. chunkId || null ) : null ) ;
612- setBottomAnchorChunkId (
613- newEnd < total ? ( transcriptParts [ newEnd - 1 ] ?. chunkId || null ) : null ,
614- ) ;
615- } else if ( wantsDownLoad ) {
616- // Slide window forward by one page; cap size at MAX_WINDOW_SIZE
617- // by trimming the same number of parts off the top. When the new
618- // window reaches the array tail, UNSET BOTH anchors to drop back
619- // into default tail-follow mode (window collapses to PAGE_SIZE,
620- // live appends auto-flow into view).
621- const newEnd = Math . min ( total , visibleEndIdx + TRANSCRIPT_PAGE_SIZE ) ;
622- const newStart = Math . max ( 0 , newEnd - MAX_WINDOW_SIZE ) ;
623- const reachedTail = newEnd === total ;
624- setTopAnchorChunkId (
625- reachedTail || newStart === 0
626- ? null
627- : ( transcriptParts [ newStart ] ?. chunkId || null ) ,
628- ) ;
629- setBottomAnchorChunkId (
630- reachedTail ? null : ( transcriptParts [ newEnd - 1 ] ?. chunkId || null ) ,
631- ) ;
632- }
625+ // Slide window forward by one page. When new window reaches the array
626+ // tail, UNSET BOTH anchors to drop back into default tail-follow mode.
627+ const newEnd = Math . min ( total , visibleEndIdx + TRANSCRIPT_PAGE_SIZE ) ;
628+ const newStart = Math . max ( 0 , newEnd - MAX_WINDOW_SIZE ) ;
629+ const reachedTail = newEnd === total ;
630+ setTopAnchorChunkId (
631+ reachedTail || newStart === 0
632+ ? null
633+ : ( transcriptParts [ newStart ] ?. chunkId || null ) ,
634+ ) ;
635+ setBottomAnchorChunkId (
636+ reachedTail ? null : ( transcriptParts [ newEnd - 1 ] ?. chunkId || null ) ,
637+ ) ;
633638
634639 requestAnimationFrame ( ( ) => {
635640 const elNow = transcriptRef . current ;
636641 if ( elNow ) restoreScrollAnchor ( elNow , anchor ) ;
637- loadingMoreRef . current = false ;
642+ setTimeout ( ( ) => { loadingMoreRef . current = false ; } , 600 ) ;
638643 } ) ;
639- } , [ hasOlderParts , hasNewerParts , visibleStartIdx , visibleEndIdx , transcriptParts ] ) ;
644+ } , [ hasNewerParts , visibleEndIdx , transcriptParts ] ) ;
645+
646+ // Attach IntersectionObserver to whichever sentinels are mounted. The
647+ // observer fires only on viewport-entry transitions (isIntersecting
648+ // true → only when the user scrolls so the sentinel comes INTO view from
649+ // OFF-screen). After load, scroll restoration keeps user at the same
650+ // chunk → sentinel slides OUT of viewport → observer state resets, ready
651+ // for the next user-initiated scroll to top/bottom.
652+ useEffect ( ( ) => {
653+ const container = transcriptRef . current ;
654+ if ( ! container ) return ;
655+
656+ const observer = new IntersectionObserver (
657+ ( entries ) => {
658+ for ( const entry of entries ) {
659+ if ( ! entry . isIntersecting ) continue ;
660+ if ( entry . target === topSentinelRef . current ) {
661+ loadOlder ( ) ;
662+ } else if ( entry . target === bottomSentinelRef . current ) {
663+ loadNewer ( ) ;
664+ }
665+ }
666+ } ,
667+ {
668+ root : container ,
669+ // 10% visibility = clear "user actually scrolled to see it".
670+ // Lower would fire on momentum bounces; higher would require
671+ // dragging fully on-screen which feels sluggish.
672+ threshold : 0.1 ,
673+ } ,
674+ ) ;
675+
676+ if ( topSentinelRef . current ) observer . observe ( topSentinelRef . current ) ;
677+ if ( bottomSentinelRef . current ) observer . observe ( bottomSentinelRef . current ) ;
678+ return ( ) => observer . disconnect ( ) ;
679+ } , [ loadOlder , loadNewer , hasOlderParts , hasNewerParts ] ) ;
640680
641681 const persistTranscriptParts = async ( parts : TranscriptPart [ ] ) => {
642682 const meetingId = currentMeetingId || draftId ;
@@ -1156,7 +1196,6 @@ export function MeetingDetail() {
11561196 < div
11571197 className = { `transcript-list ${ translationEnabled ? 'with-translation' : '' } ` }
11581198 ref = { transcriptRef }
1159- onScroll = { handleTranscriptScroll }
11601199 >
11611200 { /* v1.2.14: Internet-offline banner. Self-renders only
11621201 when the backend's TCP probe (1.1.1.1:443) flips to
@@ -1220,17 +1259,22 @@ export function MeetingDetail() {
12201259 </ div >
12211260 ) ;
12221261 } ) ( ) }
1223- { /* Sentinel shown at top of list when older parts
1224- are hidden. Pure status indicator — no click
1225- handler. Scrolling within SCROLL_LOAD_THRESHOLD
1226- px of this sentinel auto-loads the next page. */ }
1262+ { /* Top sentinel — fires IntersectionObserver when it
1263+ scrolls into view (10% visible), triggering loadOlder.
1264+ Only one load per user-initiated scroll into the
1265+ sentinel — observer fires on transition only, plus
1266+ 600ms cooldown safety net. */ }
12271267 { hasOlderParts && (
1228- < div className = "transcript-load-sentinel" aria-live = "polite" >
1268+ < div
1269+ ref = { topSentinelRef }
1270+ className = "transcript-load-sentinel"
1271+ aria-live = "polite"
1272+ >
12291273 < span className = "transcript-load-sentinel-spinner" aria-hidden />
12301274 < span >
12311275 { lang === 'vi'
1232- ? `Đang ẩn ${ visibleStartIdx } đoạn cũ hơn — cuộn lên để tải thêm`
1233- : `${ visibleStartIdx } older part(s) hidden — scroll up to load more` }
1276+ ? `Đang ẩn ${ visibleStartIdx } đoạn cũ hơn — cuộn đến đây để tải thêm`
1277+ : `${ visibleStartIdx } older part(s) hidden — scroll here to load more` }
12341278 </ span >
12351279 </ div >
12361280 ) }
@@ -1317,17 +1361,20 @@ export function MeetingDetail() {
13171361 </ div >
13181362 ) ;
13191363 } ) }
1320- { /* Bottom sentinel — surfaces when the sliding window
1321- has trimmed newer parts off the bottom (user scrolled
1322- up past MAX_WINDOW_SIZE then back). Cuộn xuống tự
1323- động slide window forward. */ }
1364+ { /* Bottom sentinel — IntersectionObserver triggers
1365+ loadNewer when it reaches viewport. Same one-shot
1366+ transition + 600ms cooldown semantics as top. */ }
13241367 { hasNewerParts && (
1325- < div className = "transcript-load-sentinel" aria-live = "polite" >
1368+ < div
1369+ ref = { bottomSentinelRef }
1370+ className = "transcript-load-sentinel"
1371+ aria-live = "polite"
1372+ >
13261373 < span className = "transcript-load-sentinel-spinner" aria-hidden />
13271374 < span >
13281375 { lang === 'vi'
1329- ? `Đang ẩn ${ transcriptParts . length - visibleEndIdx } đoạn mới hơn — cuộn xuống để tải`
1330- : `${ transcriptParts . length - visibleEndIdx } newer part(s) hidden — scroll down to load` }
1376+ ? `Đang ẩn ${ transcriptParts . length - visibleEndIdx } đoạn mới hơn — cuộn đến đây để tải`
1377+ : `${ transcriptParts . length - visibleEndIdx } newer part(s) hidden — scroll here to load` }
13311378 </ span >
13321379 </ div >
13331380 ) }
0 commit comments