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:
parent
0394a5bdc2
commit
df2ccd9840
@ -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`}>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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' />}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
200
components/use-comments-navigator.js
Normal file
200
components/use-comments-navigator.js
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user