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 ({
|
export default function Comment ({
|
||||||
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
|
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 [edit, setEdit] = useState()
|
||||||
const { me } = useMe()
|
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
|
// don't try to unset the outline if the comment is not outlined or we already unset the outline
|
||||||
if (hasOutline && !hasOutlineUnset) {
|
if (hasOutline && !hasOutlineUnset) {
|
||||||
ref.current.classList.add('outline-new-comment-unset')
|
ref.current.classList.add('outline-new-comment-unset')
|
||||||
|
// untrack the new comment
|
||||||
|
navigator?.untrackNewComment(ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +176,8 @@ export default function Comment ({
|
|||||||
} else {
|
} else {
|
||||||
ref.current.classList.add('outline-new-comment')
|
ref.current.classList.add('outline-new-comment')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigator?.trackNewComment(ref, itemCreatedAt)
|
||||||
}, [item.id, rootLastCommentAt])
|
}, [item.id, rootLastCommentAt])
|
||||||
|
|
||||||
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
|
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) => (
|
{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 && (
|
{item.comments.comments.length < item.nDirectComments && (
|
||||||
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
|
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
|
||||||
|
@ -137,34 +137,30 @@
|
|||||||
padding-top: .5rem;
|
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 {
|
.injectedComment {
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
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 MoreFooter from './more-footer'
|
||||||
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||||
import useLiveComments from './use-live-comments'
|
import useLiveComments from './use-live-comments'
|
||||||
|
import { useCommentsNavigatorContext } from './use-comments-navigator'
|
||||||
|
|
||||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||||
const router = useRouter()
|
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
|
// fetch new comments that arrived after the lastCommentAt, and update the item.comments field in cache
|
||||||
useLiveComments(parentId, lastCommentAt || parentCreatedAt, router.query.sort)
|
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])
|
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -94,11 +98,11 @@ export default function Comments ({
|
|||||||
: null}
|
: null}
|
||||||
{pins.map(item => (
|
{pins.map(item => (
|
||||||
<Fragment key={item.id}>
|
<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>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{comments.filter(({ position }) => !position).map(item => (
|
{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 &&
|
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||||
<MoreFooter
|
<MoreFooter
|
||||||
|
@ -2,9 +2,11 @@ import { Nav, Navbar } from 'react-bootstrap'
|
|||||||
import styles from '../../header.module.css'
|
import styles from '../../header.module.css'
|
||||||
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
|
import { AnonCorner, Back, Brand, MeCorner, NavPrice, SearchItem } from '../common'
|
||||||
import { useMe } from '../../me'
|
import { useMe } from '../../me'
|
||||||
|
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
|
||||||
|
|
||||||
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
|
export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
const { navigator, commentCount } = useCommentsNavigatorContext()
|
||||||
return (
|
return (
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<Nav
|
<Nav
|
||||||
@ -15,6 +17,7 @@ export default function TopBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
|
|||||||
<Brand className='me-1' />
|
<Brand className='me-1' />
|
||||||
<SearchItem prefix={prefix} className='me-0 ms-2 d-none d-md-flex' />
|
<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' />
|
<NavPrice className='ms-auto me-0 mx-md-auto d-none d-md-flex' />
|
||||||
|
<CommentsNavigator navigator={navigator} commentCount={commentCount} />
|
||||||
{me
|
{me
|
||||||
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
|
? <MeCorner dropNavKey={dropNavKey} me={me} className='d-none d-md-flex' />
|
||||||
: <AnonCorner path={path} 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 styles from '../../header.module.css'
|
||||||
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
|
import { Back, NavPrice, NavSelect, NavWalletSummary, SignUpButton, hasNavSelect } from '../common'
|
||||||
import { useMe } from '@/components/me'
|
import { useMe } from '@/components/me'
|
||||||
|
import { useCommentsNavigatorContext, CommentsNavigator } from '@/components/use-comments-navigator'
|
||||||
|
|
||||||
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
|
export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNavKey }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
const { navigator, commentCount } = useCommentsNavigatorContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<Nav
|
<Nav
|
||||||
@ -17,6 +20,7 @@ export default function TopBar ({ prefix, sub, path, pathname, topNavKey, dropNa
|
|||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<NavPrice className='flex-shrink-1' />
|
<NavPrice className='flex-shrink-1' />
|
||||||
|
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='px-2' />
|
||||||
{me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
|
{me ? <NavWalletSummary /> : <SignUpButton width='fit-content' />}
|
||||||
</>)}
|
</>)}
|
||||||
</Nav>
|
</Nav>
|
||||||
|
@ -4,10 +4,12 @@ import { Container, Nav, Navbar } from 'react-bootstrap'
|
|||||||
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
|
import { NavPrice, MeCorner, AnonCorner, SearchItem, Back, NavWalletSummary, Brand, SignUpButton } from './common'
|
||||||
import { useMe } from '@/components/me'
|
import { useMe } from '@/components/me'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import { CommentsNavigator, useCommentsNavigatorContext } from '../use-comments-navigator'
|
||||||
|
|
||||||
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
|
export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey }) {
|
||||||
const ref = useRef()
|
const ref = useRef()
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
const { navigator, commentCount } = useCommentsNavigatorContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stick = () => {
|
const stick = () => {
|
||||||
@ -37,6 +39,7 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
|
|||||||
<Brand className='me-1' />
|
<Brand className='me-1' />
|
||||||
<SearchItem className='me-0 ms-2' />
|
<SearchItem className='me-0 ms-2' />
|
||||||
<NavPrice />
|
<NavPrice />
|
||||||
|
<CommentsNavigator navigator={navigator} commentCount={commentCount} className='d-flex' />
|
||||||
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
|
{me ? <MeCorner dropNavKey={dropNavKey} me={me} className='d-flex' /> : <AnonCorner path={path} className='d-flex' />}
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
@ -44,11 +47,12 @@ export default function StickyBar ({ prefix, sub, path, topNavKey, dropNavKey })
|
|||||||
<Container className='px-sm-0 d-block d-md-none'>
|
<Container className='px-sm-0 d-block d-md-none'>
|
||||||
<Navbar className='py-0'>
|
<Navbar className='py-0'>
|
||||||
<Nav
|
<Nav
|
||||||
className={classNames(styles.navbarNav, 'justify-content-between')}
|
className={classNames(styles.navbarNav)}
|
||||||
activeKey={topNavKey}
|
activeKey={topNavKey}
|
||||||
>
|
>
|
||||||
<Back />
|
<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' />}
|
{me ? <NavWalletSummary className='px-2' /> : <SignUpButton width='fit-content' />}
|
||||||
</Nav>
|
</Nav>
|
||||||
</Navbar>
|
</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 { useQuery } from '@apollo/client'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import PageLoading from '@/components/page-loading'
|
import PageLoading from '@/components/page-loading'
|
||||||
|
import { CommentsNavigatorProvider } from '@/components/use-comments-navigator'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({
|
export const getServerSideProps = getGetServerSideProps({
|
||||||
query: ITEM_FULL,
|
query: ITEM_FULL,
|
||||||
@ -25,8 +26,10 @@ export default function Item ({ ssrData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout sub={sub} item={item}>
|
<CommentsNavigatorProvider>
|
||||||
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
|
<Layout sub={sub} item={item}>
|
||||||
</Layout>
|
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
|
||||||
|
</Layout>
|
||||||
|
</CommentsNavigatorProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user