live comments: comments navigation (#2377)

* live comments: stable navigator for new outlined comments

* 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

* CSS visibility tweaks

* scroll to start position of ref

* fix undefined navigator on other comment calls

* remove pulse animation
This commit is contained in:
soxa 2025-08-15 20:22:06 +02:00 committed by GitHub
parent 0394a5bdc2
commit df2ccd9840
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 253 additions and 66 deletions

View File

@ -97,7 +97,8 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
export default function Comment ({
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry,
navigator
}) {
const [edit, setEdit] = useState()
const { me } = useMe()
@ -122,6 +123,8 @@ export default function Comment ({
// don't try to unset the outline if the comment is not outlined or we already unset the outline
if (hasOutline && !hasOutlineUnset) {
ref.current.classList.add('outline-new-comment-unset')
// untrack the new comment
navigator?.untrackNewComment(ref)
}
}
@ -173,6 +176,8 @@ export default function Comment ({
} else {
ref.current.classList.add('outline-new-comment')
}
navigator?.trackNewComment(ref, itemCreatedAt)
}, [item.id, rootLastCommentAt])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
@ -295,7 +300,7 @@ export default function Comment ({
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} rootLastCommentAt={rootLastCommentAt} />
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={rootLastCommentAt} />
))}
{item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}>

View File

@ -137,34 +137,30 @@
padding-top: .5rem;
}
.newCommentDot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-primary);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
background-color: #80d3ff;
opacity: 0.7;
}
50% {
background-color: #007cbe;
opacity: 1;
}
100% {
background-color: #80d3ff;
opacity: 0.7;
}
}
.injectedComment {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from { opacity: 0; }
to { opacity: 1; }
}
.commentNavigator {
display: flex;
align-items: center;
vertical-align: middle;
gap: 0.2rem;
padding-bottom: 0.2rem;
justify-content: center;
cursor: pointer;
/* prevent double tap from zooming */
touch-action: manipulation;
}
.newCommentDot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #007cbe;
}

View File

@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
import { useCommentsNavigatorContext } from './use-comments-navigator'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
@ -72,6 +73,9 @@ export default function Comments ({
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
// new comments navigator, tracks new comments and provides navigation controls
const { navigator } = useCommentsNavigatorContext()
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
return (
@ -94,11 +98,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
<Comment depth={1} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
<Comment depth={1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter

View File

@ -2,9 +2,11 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
import { useMe } from '../../me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return (
<Navbar>
<Nav
@ -15,6 +17,7 @@ export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
<Brand className='me-1' />
<SearchItem prefix={prefix} className='me-0 ms-2 d-none d-md-flex' />
<NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} />
{me
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
: <AnonCorner path={path} className='d-none d-md-flex' />}

View File

@ -2,9 +2,12 @@ import { Nav, Navbar } from 'react-bootstrap'
import styles from '../../header.module.css'
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
import { useMe } from '@/components/me'
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
return (
<Navbar>
<Nav
@ -17,6 +20,7 @@ export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNa
: (
<>
<NavPrice className='flex-shrink-1' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='px-2' />
{me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
</>)}
</Nav>

View File

@ -4,10 +4,12 @@ import { Container, Nav, Navbar } from 'react-bootstrap'
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
import { useMe } from '@/components/me'
import classNames from 'classnames'
import { CommentsNavigator, useCommentsNavigatorContext } from '../use-comments-navigator'
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
const ref = useRef()
const { me } = useMe()
const { navigator, commentCount } = useCommentsNavigatorContext()
useEffect(() => {
const stick = () => {
@ -37,6 +39,7 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Brand className='me-1' />
<SearchItem className='me-0 ms-2' />
<NavPrice />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
</Nav>
</Navbar>
@ -44,11 +47,12 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
<Container className='px-sm-0 d-block d-md-none'>
<Navbar className='py-0'>
<Nav
className={classNames(styles.navbarNav, 'justify-content-between')}
className={classNames(styles.navbarNav)}
activeKey={topNavKey}
>
<Back />
<NavPrice className='flex-shrink-1 flex-grow-0' />
<NavPrice className='flex-shrink-1' />
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
</Nav>
</Navbar>

View File

@ -0,0 +1,200 @@
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'
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 [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))
}, [])
// track a new comment
const trackNewComment = useCallback((commentRef, createdAt) => {
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 = {}) => {
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>
)
}

View File

@ -1,32 +0,0 @@
import { useEffect, useState } from 'react'
// observe the passed element ref and return its visibility
export default function useVisibility (elementRef, options = {}) {
// threshold is the percentage of the element that must be visible to be considered visible
// with pastElement, we consider the element not visible only when we're past it
const { threshold = 0, pastElement = false } = options
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const element = elementRef.current
if (!element || !window.IntersectionObserver || typeof window === 'undefined') return
const observer = new window.IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
} else if (pastElement) {
setIsVisible(entry.boundingClientRect.top > 0)
} else {
setIsVisible(false)
}
}, { threshold }
)
// observe the passed element ref
observer.observe(element)
return () => observer.disconnect()
}, [threshold, elementRef, pastElement])
return isVisible
}

View File

@ -5,6 +5,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
import { useQuery } from '@apollo/client'
import { useRouter } from 'next/router'
import PageLoading from '@/components/page-loading'
import { CommentsNavigatorProvider } from '@/components/use-comments-navigator'
export const getServerSideProps = getGetServerSideProps({
query: ITEM_FULL,
@ -25,8 +26,10 @@ export default function Item ({ ssrData }) {
}
return (
<Layout sub={sub} item={item}>
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
</Layout>
<CommentsNavigatorProvider>
<Layout sub={sub} item={item}>
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
</Layout>
</CommentsNavigatorProvider>
)
}