* Fix duplicate comment on pessimistic creation - comment creation checks for comment's ID existence in cache - invoice.confirmedAt included in useCanEdit deps for anons live comments * switch to some as sets are not worth it * only check for duplicates if a pessimistic payment method has been used * default to empty array * add comment about side-effects * record ownership of an item to avoid injecting it via live comments * trigger check only if the incoming comment is ours, cleanup * correct conditions, correct comments, light cleanup * fix: add defensive condition to ownership recorder, better name * refactor: unified comment injection logic with deduplication, useCommentsView hook; revert sessionStorage-based fix * adjust live comments naming around the codebase * listen for hmac presence for anon edits * always return the injected comment createdAt to bump live comments * refactor: improve live comments hook readability - latest comment createdAt persistence helper - preserveScroll returns the returning value of the callback - compact conditional logic - refresh code comments - refresh naming - group constants - reorder imports * flat comment injection, fetch flat comments instead of the entire subtree that would've been deduplicated anyway, cleanup * always align new comment fragment to the comments query structure * generic useCommentsView hook * update comment counts if live injecting into fragments without comments field * fix: pass parentId, if a comment has a top level parent it always has the comments field * fix: update CommentsViewAt only if we actually injected a comment into cache * correct injectComment result usage * pass markViewedAt to further centralize side effects, remove live from Item server typedefs * fix: don't update counts for ancestors that are already up to date, update commentsViewedAt per batch not per comment * port: fix coalesce, useCommentsView hook and outline changes * update hmac field in cache on paid invoice, hmac as useCanEdit effect dependency * comments and light cleanup, update useCommentsView * efficient hasComments logic for live comments, establish a gql fragment * fix: typo on topLevel evaluation * limit extra evaluations to live comments scenarios * update comments * support live comments ncomments increments for anon view tracking
201 lines
6.5 KiB
JavaScript
201 lines
6.5 KiB
JavaScript
import { useCallback, useEffect, useRef, useState, startTransition, createContext, useContext } from 'react'
|
|
import styles from './comment.module.css'
|
|
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 { 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
|
|
|
|
// 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-live-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 unmount
|
|
useEffect(() => {
|
|
return () => clearCommentRefs()
|
|
}, [clearCommentRefs])
|
|
|
|
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>
|
|
)
|
|
}
|