stacker.news/components/use-comments-navigator.js
soxa 610e6dcb91
live comments: favicon (#2400)
* live comments: stable navigator for new outlined comments

* favicons: FaviconProvider, handle new comments favicon via navigator

* navigator keyboard shortcuts: arrow right/escape key

* enhance: responsive fixed positioning; cleanup

enhance:
- two types of padding for desktop and mobile via CSS

cleanup:
- use appropriate <aside> for navigator
- reorder CSS

* Comments Navigator Context, new comments dot UI, refs autosorting, auto-untrack children

- Navigator Context for item pages

UI/UX
- WIP: compact comments dot UI on navbars
- long press to clear tracked refs
- auto-untrack node's children on scroll

Logic
- auto-sort comment refs via createdAt
- remove outline on untrack if called by scroll

* stable navigator dot UI positioning

* cleanup: better naming, clear structure

* re-instate favicon state updates on navigator

* CSS visibility tweaks

* scroll to start position of ref

* fix undefined navigator on other comment calls

* add explanation for early favicon clear

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-08-15 13:43:31 -05:00

208 lines
6.9 KiB
JavaScript

import { useCallback, useEffect, useRef, useState, startTransition, createContext, useContext } from 'react'
import styles from './comment.module.css'
import { useRouter } from 'next/router'
import LongPressable from './long-pressable'
import { useFavicon } from './favicon'
const CommentsNavigatorContext = createContext({
navigator: {
trackNewComment: () => {},
untrackNewComment: () => {},
scrollToComment: () => {},
clearCommentRefs: () => {}
},
commentCount: 0
})
export function CommentsNavigatorProvider ({ children }) {
const value = useCommentsNavigator()
return (
<CommentsNavigatorContext.Provider value={value}>
{children}
</CommentsNavigatorContext.Provider>
)
}
export function useCommentsNavigatorContext () {
return useContext(CommentsNavigatorContext)
}
export function useCommentsNavigator () {
const router = useRouter()
const { setHasNewComments } = useFavicon()
const [commentCount, setCommentCount] = useState(0)
// refs in ref to not re-render on tracking
const commentRefs = useRef([])
// ref to track if the comment count is being updated
const frameRef = useRef(null)
const navigatorRef = useRef(null)
// batch updates to the comment count
const throttleCountUpdate = useCallback(() => {
if (frameRef.current) return
// prevent multiple updates in the same frame
frameRef.current = true
window.requestAnimationFrame(() => {
const next = commentRefs.current.length
// transition to the new comment count
startTransition?.(() => setCommentCount(next))
frameRef.current = false
})
}, [])
// clear the list of refs and reset the comment count
const clearCommentRefs = useCallback(() => {
commentRefs.current = []
startTransition?.(() => setCommentCount(0))
setHasNewComments(false)
}, [])
// track a new comment
const trackNewComment = useCallback((commentRef, createdAt) => {
setHasNewComments(true)
try {
window.requestAnimationFrame(() => {
if (!commentRef?.current || !commentRef.current.isConnected) return
// don't track this new comment if it's visible in the viewport
const rect = commentRef.current.getBoundingClientRect()
if (rect.top >= 0 && rect.bottom <= window.innerHeight) return
// dedupe
const existing = commentRefs.current.some(item => item.ref.current === commentRef.current)
if (existing) return
// find the correct insertion position to maintain sort order
const insertIndex = commentRefs.current.findIndex(item => item.createdAt > createdAt)
const newItem = { ref: commentRef, createdAt }
if (insertIndex === -1) {
// append if no newer comments found
commentRefs.current.push(newItem)
} else {
// insert at the correct position to maintain sort order
commentRefs.current.splice(insertIndex, 0, newItem)
}
throttleCountUpdate()
})
} catch {
// in the rare case of a ref being disconnected during RAF, ignore to avoid blocking UI
}
}, [throttleCountUpdate])
// remove a comment ref from the list
const untrackNewComment = useCallback((commentRef, options = {}) => {
// we just need to read a single comment to clear the favicon
setHasNewComments(false)
const { includeDescendants = false, clearOutline = false } = options
const refNode = commentRef.current
if (!refNode) return
const toRemove = commentRefs.current.filter(item => {
const node = item?.ref?.current
return includeDescendants
? node && refNode.contains(node)
: node === refNode
})
if (clearOutline) {
for (const item of toRemove) {
const node = item.ref.current
if (!node) continue
node.classList.remove(
'outline-it',
'outline-new-comment',
'outline-new-injected-comment'
)
node.classList.add('outline-new-comment-unset')
}
}
if (toRemove.length) {
commentRefs.current = commentRefs.current.filter(item => !toRemove.includes(item))
throttleCountUpdate()
}
}, [throttleCountUpdate])
// scroll to the next new comment
const scrollToComment = useCallback(() => {
const list = commentRefs.current
if (!list.length) return
const ref = list[0]?.ref
const node = ref?.current
if (!node) return
// smoothly scroll to the start of the comment
node.scrollIntoView({ behavior: 'smooth', block: 'start' })
// clear the outline class after the animation ends
node.addEventListener('animationend', () => {
node.classList.remove('outline-it')
}, { once: true })
// requestAnimationFrame to ensure untracking is processed before outlining
window.requestAnimationFrame(() => {
node.classList.add('outline-it')
})
// untrack the new comment and clear the outlines
untrackNewComment(ref, { includeDescendants: true, clearOutline: true })
// if we reached the end, reset the navigator
if (list.length === 1) clearCommentRefs()
}, [clearCommentRefs, untrackNewComment])
// create the navigator object once
if (!navigatorRef.current) {
navigatorRef.current = { trackNewComment, untrackNewComment, scrollToComment, clearCommentRefs }
}
// clear the navigator on route changes
useEffect(() => {
router.events.on('routeChangeStart', clearCommentRefs)
return () => router.events.off('routeChangeStart', clearCommentRefs)
}, [clearCommentRefs, router.events])
return { navigator: navigatorRef.current, commentCount }
}
export function CommentsNavigator ({ navigator, commentCount, className }) {
const { scrollToComment, clearCommentRefs } = navigator
const onNext = useCallback((e) => {
// ignore if there are no new comments or if we're focused on a textarea or input
if (!commentCount || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return
// arrow right key scrolls to the next new comment
if (e.key === 'ArrowRight' && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
e.preventDefault()
scrollToComment()
}
// escape key clears the new comments navigator
if (e.key === 'Escape') clearCommentRefs()
}, [commentCount, scrollToComment, clearCommentRefs])
useEffect(() => {
if (!commentCount) return
document.addEventListener('keydown', onNext)
return () => document.removeEventListener('keydown', onNext)
}, [onNext])
return (
<LongPressable onShortPress={scrollToComment} onLongPress={clearCommentRefs}>
<aside
className={`${styles.commentNavigator} fw-bold nav-link ${className}`}
style={{ visibility: commentCount ? 'visible' : 'hidden' }}
>
<span aria-label='next comment' className={styles.navigatorButton}>
<div className={styles.newCommentDot} />
</span>
<span className=''>{commentCount}</span>
</aside>
</LongPressable>
)
}