live comments: auto show new comments (#2355)
* enhance: FaviconProvider, keep track of new comment IDs to change favicon, remove new comment IDs per outline removal * don't track oneself comments * enhance: auto-show new comments, idempotency by ignoring already injected comments, preserveScroll utility * fadeIn animation on comment injection; cleanup: remove unused counts and thread handling; non-critical fix: always give rootLastCommentAt a value * reliably preserve scroll position by tracking a reference found at the center of the viewport; cleanup: add more comments, add cleanup function * mitigate fractional scrolling subtle layout shifts by rounding the new reference element position * enhanced outlining system, favicon context keeps track of new comments presence - de-outlining now happens only for outlined comments - enhanced outlining: add outline only if isNewComment - de-outlining will remove the new comments favicon - on unmount remove the new comments favicon * remove the new comments favicon on new comments injection * track only deduplicated new comments * fix typo * clearer unsetOutline conditions, fix typo in live comments hook * backport: remove the injectedComment class from injected comments after animation ends * set the new comments favicon on any new outlined comment * enhance: directly inject new comments; cleanup: dismantle ShowNewComments, remove newComments field * tweaks: slower injection animation, clear favicon on Comment section unmount * change nDirectComments bug strategy to avoiding updates on comment edit * cleanup: better naming, re-instate injected comments outline * injection: major cache utilities refactor, don't preserve scroll if no comments have been injected - don't preserve scroll if after deduplication we don't inject any comments - use manual read/write cache updates to control the flow -- allows to check if we are really injecting or not - reduce polling to 5 seconds instead of 10 - light cleanup -- removed update cache functions -- added 'injected' to typeDefs (gql consistency) * cleanup: detailed comments, refactor, remove clutter Refactor: + clearer variables + depth calculation utility function + use destructured Apollo cache + extract item object from item query + skip ignored comment instead of ending the loop CSS: + from-to fadeIn animation keyframes - floatingComments unused class Favicon: + provider exported by default * fix wrong merge * split: remove favicon context * split: remove favicon pngs * regression: revert to updateQuery for multiple comment fragments handling * reverse multiple reads for deduplication on comment injection * fix regression on apollo manipulations via fn; cleanup: remove wrong deps from outlining
This commit is contained in:
parent
1bda8a6de2
commit
0e842e9915
@ -149,7 +149,7 @@ export default gql`
|
||||
ncomments: Int!
|
||||
nDirectComments: Int!
|
||||
comments(sort: String, cursor: String): Comments!
|
||||
newComments(rootId: ID, after: Date): Comments!
|
||||
injected: Boolean!
|
||||
path: String
|
||||
position: Int
|
||||
prior: Int
|
||||
|
@ -28,7 +28,6 @@ import LinkToContext from './link-to-context'
|
||||
import Boost from './boost-button'
|
||||
import { gql, useApolloClient } from '@apollo/client'
|
||||
import classNames from 'classnames'
|
||||
import { ShowNewComments } from './show-new-comments'
|
||||
|
||||
function Parent ({ item, rootText }) {
|
||||
const root = useRoot()
|
||||
@ -115,6 +114,17 @@ export default function Comment ({
|
||||
|
||||
const { cache } = useApolloClient()
|
||||
|
||||
const unsetOutline = () => {
|
||||
if (!ref.current) return
|
||||
const hasOutline = ref.current.classList.contains('outline-new-comment') || ref.current.classList.contains('outline-new-injected-comment')
|
||||
const hasOutlineUnset = ref.current.classList.contains('outline-new-comment-unset')
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const comment = cache.readFragment({
|
||||
id: `Item:${router.query.commentId}`,
|
||||
@ -142,17 +152,26 @@ export default function Comment ({
|
||||
|
||||
useEffect(() => {
|
||||
if (me?.id === item.user?.id) return
|
||||
const itemCreatedAt = new Date(item.createdAt).getTime()
|
||||
|
||||
if (router.query.commentsViewedAt &&
|
||||
!item.injected &&
|
||||
itemCreatedAt > router.query.commentsViewedAt) {
|
||||
ref.current.classList.add('outline-new-comment')
|
||||
// newly injected comments have to use a different class to outline every new comment
|
||||
} else if (rootLastCommentAt &&
|
||||
item.injected &&
|
||||
itemCreatedAt > new Date(rootLastCommentAt).getTime()) {
|
||||
const itemCreatedAt = new Date(item.createdAt).getTime()
|
||||
// it's a new comment if it was created after the last comment was viewed
|
||||
// or, in the case of live comments, after the last comment was created
|
||||
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
|
||||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
|
||||
if (!isNewComment) return
|
||||
|
||||
if (item.injected) {
|
||||
// newly injected comments (item.injected) have to use a different class to outline every new comment
|
||||
ref.current.classList.add('outline-new-injected-comment')
|
||||
|
||||
// wait for the injection animation to end before removing its class
|
||||
ref.current.addEventListener('animationend', () => {
|
||||
ref.current.classList.remove(styles.injectedComment)
|
||||
}, { once: true })
|
||||
// animate the live comment injection
|
||||
ref.current.classList.add(styles.injectedComment)
|
||||
} else {
|
||||
ref.current.classList.add('outline-new-comment')
|
||||
}
|
||||
}, [item.id, rootLastCommentAt])
|
||||
|
||||
@ -168,8 +187,8 @@ export default function Comment ({
|
||||
return (
|
||||
<div
|
||||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
||||
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||
onMouseEnter={unsetOutline}
|
||||
onTouchStart={unsetOutline}
|
||||
>
|
||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||
{item.outlawed && !me?.privates?.wildWestMode
|
||||
@ -269,9 +288,6 @@ export default function Comment ({
|
||||
: !noReply &&
|
||||
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
|
||||
{root.bounty && !bountyPaid && <PayBounty item={item} />}
|
||||
<div className='ms-auto'>
|
||||
<ShowNewComments item={item} depth={depth} />
|
||||
</div>
|
||||
</Reply>}
|
||||
{children}
|
||||
<div className={styles.comments}>
|
||||
@ -300,7 +316,6 @@ export default function Comment ({
|
||||
|
||||
export function ViewMoreReplies ({ item, navigateRoot = false }) {
|
||||
const root = useRoot()
|
||||
const { cache } = useApolloClient()
|
||||
const id = navigateRoot ? commentSubTreeRootId(item, root) : item.id
|
||||
|
||||
const href = `/items/${id}` + (navigateRoot ? '' : `?commentId=${item.id}`)
|
||||
@ -314,23 +329,8 @@ export function ViewMoreReplies ({ item, navigateRoot = false }) {
|
||||
href={href}
|
||||
as={`/items/${id}`}
|
||||
className='fw-bold d-flex align-items-center gap-2 text-muted'
|
||||
onClick={() => {
|
||||
if (!item.newComments?.length) return
|
||||
// clear new comments going to another page
|
||||
cache.writeFragment({
|
||||
id: `Item:${item.id}`,
|
||||
fragment: gql`
|
||||
fragment NewComments on Item {
|
||||
newComments
|
||||
}`,
|
||||
data: {
|
||||
newComments: []
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{item.newComments?.length > 0 && <div className={styles.newCommentDot} />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -160,20 +160,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.floatingComments {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1050;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
.injectedComment {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(-100px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ import { useRouter } from 'next/router'
|
||||
import MoreFooter from './more-footer'
|
||||
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||
import useLiveComments from './use-live-comments'
|
||||
import { ShowNewComments } from './show-new-comments'
|
||||
|
||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||
const router = useRouter()
|
||||
@ -66,17 +65,17 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
||||
|
||||
export default function Comments ({
|
||||
parentId, pinned, bio, parentCreatedAt,
|
||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, item, ...props
|
||||
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
|
||||
}) {
|
||||
const router = useRouter()
|
||||
// fetch new comments that arrived after the lastCommentAt, and update the item.newComments 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)
|
||||
|
||||
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShowNewComments topLevel item={item} sort={router.query.sort} />
|
||||
{comments?.length > 0
|
||||
? <CommentsHeader
|
||||
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
|
||||
@ -95,11 +94,11 @@ export default function Comments ({
|
||||
: null}
|
||||
{pins.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt} {...props} pin />
|
||||
<Comment depth={1} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
|
||||
</Fragment>
|
||||
))}
|
||||
{comments.filter(({ position }) => !position).map(item => (
|
||||
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt} {...props} />
|
||||
<Comment depth={1} key={item.id} item={item} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
|
||||
))}
|
||||
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||
<MoreFooter
|
||||
|
@ -7,7 +7,6 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import Price from '../price'
|
||||
import SubSelect from '../sub-select'
|
||||
import { USER_ID } from '../../lib/constants'
|
||||
import Head from 'next/head'
|
||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||
import { useMe } from '../me'
|
||||
import { abbrNum } from '../../lib/format'
|
||||
@ -121,9 +120,6 @@ export function NavNotifications ({ className }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<link rel='shortcut icon' href={hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
|
||||
</Head>
|
||||
<Link href='/notifications' passHref legacyBehavior>
|
||||
<Nav.Link eventKey='notifications' className={classNames('position-relative', className)}>
|
||||
<NoteIcon height={28} width={20} className='theme' />
|
||||
|
53
components/preserve-scroll.js
Normal file
53
components/preserve-scroll.js
Normal file
@ -0,0 +1,53 @@
|
||||
export default function preserveScroll (callback) {
|
||||
// preserve the actual scroll position
|
||||
const scrollTop = window.scrollY
|
||||
|
||||
// if the scroll position is at the top, we don't need to preserve it, just call the callback
|
||||
if (scrollTop <= 0) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
|
||||
// get a reference element at the center of the viewport to track if content is added above it
|
||||
const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop
|
||||
|
||||
// observe the document for changes in height
|
||||
const observer = new window.MutationObserver(() => {
|
||||
// request animation frame to ensure the DOM is updated
|
||||
window.requestAnimationFrame(() => {
|
||||
// we can't proceed if we couldn't find a traceable reference element
|
||||
if (!ref) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// get the new position of the reference element along with the new scroll position
|
||||
const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY
|
||||
// has the reference element moved?
|
||||
const refMoved = newRefTop - refTop
|
||||
|
||||
// if the reference element moved, we need to scroll to the new position
|
||||
if (refMoved > 0) {
|
||||
window.scrollTo({
|
||||
// some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer
|
||||
top: scrollTop + Math.ceil(refMoved),
|
||||
behavior: 'instant'
|
||||
})
|
||||
}
|
||||
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => cleanup(), 1000) // fallback
|
||||
|
||||
function cleanup () {
|
||||
clearTimeout(timeout)
|
||||
observer.disconnect()
|
||||
}
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
callback()
|
||||
}
|
@ -1,204 +0,0 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useApolloClient } from '@apollo/client'
|
||||
import styles from './comment.module.css'
|
||||
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
||||
import { commentsViewedAfterComment } from '../lib/new-comments'
|
||||
import classNames from 'classnames'
|
||||
import useVisibility from './use-visibility'
|
||||
import {
|
||||
itemUpdateQuery,
|
||||
commentUpdateFragment,
|
||||
getLatestCommentCreatedAt,
|
||||
updateAncestorsCommentCount,
|
||||
readCommentsFragment
|
||||
} from '../lib/comments'
|
||||
|
||||
// filters out new comments, by id, that already exist in the item's comments
|
||||
// preventing duplicate comments from being injected
|
||||
function dedupeNewComments (newComments, comments = []) {
|
||||
const existingIds = new Set(comments.map(c => c.id))
|
||||
return newComments.filter(id => !existingIds.has(id))
|
||||
}
|
||||
|
||||
// of an array of new comments, count each new comment + all their existing comments
|
||||
function countNComments (newComments) {
|
||||
let totalNComments = newComments.length
|
||||
for (const comment of newComments) {
|
||||
totalNComments += comment.ncomments || 0
|
||||
}
|
||||
return totalNComments
|
||||
}
|
||||
|
||||
// prepares and creates a new comments fragment for injection into the cache
|
||||
// returns a function that can be used to update an item's comments field
|
||||
function prepareComments (data, client, newComments) {
|
||||
const totalNComments = countNComments(newComments)
|
||||
|
||||
const itemHierarchy = data.path.split('.')
|
||||
const ancestors = itemHierarchy.slice(0, -1)
|
||||
const rootId = itemHierarchy[0]
|
||||
|
||||
// update all ancestors, but not the item itself
|
||||
updateAncestorsCommentCount(client.cache, ancestors, totalNComments)
|
||||
|
||||
// update commentsViewedAt with the most recent fresh new comment
|
||||
// quirk: this is not the most recent comment, it's the most recent comment in the newComments array
|
||||
// as such, the next visit will not outline other new comments that are older than this one.
|
||||
const latestCommentCreatedAt = getLatestCommentCreatedAt(newComments, data.createdAt)
|
||||
commentsViewedAfterComment(rootId, latestCommentCreatedAt, totalNComments)
|
||||
|
||||
// an item can either have a comments.comments field, or not
|
||||
const payload = data.comments
|
||||
? {
|
||||
...data,
|
||||
ncomments: data.ncomments + totalNComments,
|
||||
newComments: [],
|
||||
comments: {
|
||||
...data.comments,
|
||||
comments: newComments.concat(data.comments.comments)
|
||||
}
|
||||
}
|
||||
// when the fragment doesn't have a comments field, we just update stats fields
|
||||
: {
|
||||
...data,
|
||||
ncomments: data.ncomments + totalNComments,
|
||||
newComments: []
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// traverses all new comments and their children
|
||||
// if it's a thread, or we're in a new comment subtree, we also consider their existing children
|
||||
function traverseNewComments (client, item, onLevel, currentDepth, inSubtree) {
|
||||
// if we're at the depth limit, stop traversing, we've reached the bottom of the visible thread
|
||||
if (currentDepth >= COMMENT_DEPTH_LIMIT) return
|
||||
|
||||
// if the current item shows less comments than its nDirectComments, it's paginated
|
||||
// we don't want to count/inject new comments in paginated items, as they shouldn't be visible
|
||||
if (item.comments?.comments?.length < item.nDirectComments) return
|
||||
|
||||
if (item.newComments && item.newComments.length > 0) {
|
||||
const dedupedNewComments = dedupeNewComments(item.newComments, item.comments?.comments)
|
||||
|
||||
// being newComments an array of comment ids, we can get their latest version from the cache
|
||||
// ensuring that we don't miss any new comments
|
||||
const freshNewComments = dedupedNewComments.map(id => {
|
||||
// mark all new comments as injected, so we can outline them
|
||||
return { ...readCommentsFragment(client, id), injected: true }
|
||||
}).filter(Boolean)
|
||||
|
||||
// at each level, we can execute a callback passing the current item's new comments, depth and ID
|
||||
onLevel(freshNewComments, currentDepth, item.id)
|
||||
|
||||
// traverse the new comment's new comments and their children
|
||||
for (const newComment of freshNewComments) {
|
||||
traverseNewComments(client, newComment, onLevel, currentDepth + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
// check for new comments in existing children
|
||||
// only if we're in a new comment subtree, or it's a thread
|
||||
if (inSubtree && item.comments?.comments) {
|
||||
for (const child of item.comments.comments) {
|
||||
traverseNewComments(client, child, onLevel, currentDepth + 1, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recursively processes and displays all new comments
|
||||
// handles comment injection at each level, respecting depth limits
|
||||
function injectNewComments (client, item, sort, currentDepth, thread) {
|
||||
traverseNewComments(client, item, (newComments, depth, itemId) => {
|
||||
if (newComments.length > 0) {
|
||||
// traverseNewComments also passes the depth of the current item
|
||||
// used to determine if in an array of new comments, we are injecting topLevels (depth 0) or not
|
||||
if (depth === 0) {
|
||||
itemUpdateQuery(client, itemId, sort, (data) => prepareComments(data, client, newComments))
|
||||
} else {
|
||||
commentUpdateFragment(client, itemId, (data) => prepareComments(data, client, newComments))
|
||||
}
|
||||
}
|
||||
}, currentDepth, thread)
|
||||
}
|
||||
|
||||
// counts all new comments of an item
|
||||
function countAllNewComments (client, item, currentDepth, thread) {
|
||||
let newCommentsCount = 0
|
||||
let threadChildren = false
|
||||
|
||||
// count by traversing the comment structure
|
||||
traverseNewComments(client, item, (newComments, depth) => {
|
||||
newCommentsCount += countNComments(newComments)
|
||||
|
||||
// if we reached a depth greater than 1, the thread's children have new comments
|
||||
if (depth > 1 && newComments.length > 0) {
|
||||
threadChildren = true
|
||||
}
|
||||
}, currentDepth, thread)
|
||||
|
||||
return { newCommentsCount, threadChildren }
|
||||
}
|
||||
|
||||
function FloatingComments ({ buttonRef, showNewComments, text }) {
|
||||
// show the floating comments button only when we're past the main top level button
|
||||
const isButtonVisible = useVisibility(buttonRef, { pastElement: true })
|
||||
|
||||
if (isButtonVisible) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(styles.floatingComments, 'btn btn-sm btn-info')}
|
||||
onClick={() => {
|
||||
// show new comments as we scroll up
|
||||
showNewComments()
|
||||
buttonRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ShowNewComments is a component that dedupes, refreshes and injects newComments into the comments field
|
||||
export function ShowNewComments ({ topLevel, item, sort, depth = 0 }) {
|
||||
const client = useApolloClient()
|
||||
const ref = useRef(null)
|
||||
|
||||
// a thread comment is a comment at depth 1 (parent)
|
||||
const thread = depth === 1
|
||||
|
||||
// recurse through all new comments and their children
|
||||
// if the item is a thread, we also consider all of their existing children
|
||||
const { newCommentsCount, threadChildren } = countAllNewComments(client, item, depth, thread)
|
||||
|
||||
// only if the item is a thread and its children have new comments, we show "show all new comments"
|
||||
const threadComment = thread && threadChildren
|
||||
|
||||
const showNewComments = useCallback(() => {
|
||||
// a top level comment doesn't pass depth, we pass its default value of 0 to signify this
|
||||
// child comments are injected from the depth they're at
|
||||
injectNewComments(client, item, sort, depth, threadComment)
|
||||
}, [client, sort, item, depth])
|
||||
|
||||
const text = !threadComment
|
||||
? `${newCommentsCount} new comment${newCommentsCount > 1 ? 's' : ''}`
|
||||
: 'show all new comments'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onClick={showNewComments}
|
||||
className='fw-bold d-flex align-items-center gap-2 px-3 pointer'
|
||||
style={{ visibility: newCommentsCount > 0 ? 'visible' : 'hidden' }}
|
||||
>
|
||||
{text}
|
||||
<div className={styles.newCommentDot} />
|
||||
</span>
|
||||
{topLevel && newCommentsCount > 0 && (
|
||||
<FloatingComments buttonRef={ref} showNewComments={showNewComments} text={text} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,47 +1,84 @@
|
||||
import { useQuery, useApolloClient } from '@apollo/client'
|
||||
import { SSR } from '../lib/constants'
|
||||
import preserveScroll from './preserve-scroll'
|
||||
import { GET_NEW_COMMENTS } from '../fragments/comments'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { itemUpdateQuery, commentUpdateFragment, getLatestCommentCreatedAt } from '../lib/comments'
|
||||
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
|
||||
import { useQuery, useApolloClient } from '@apollo/client'
|
||||
import { commentsViewedAfterComment } from '../lib/new-comments'
|
||||
import {
|
||||
updateItemQuery,
|
||||
updateCommentFragment,
|
||||
getLatestCommentCreatedAt,
|
||||
updateAncestorsCommentCount,
|
||||
calculateDepth
|
||||
} from '../lib/comments'
|
||||
|
||||
const POLL_INTERVAL = 1000 * 10 // 10 seconds
|
||||
const POLL_INTERVAL = 1000 * 5 // 5 seconds
|
||||
|
||||
// merge new comment into item's newComments
|
||||
// and prevent duplicates by checking if the comment is already in item's newComments or existing comments
|
||||
function mergeNewComment (item, newComment) {
|
||||
const existingNewComments = item.newComments || []
|
||||
// prepares and creates a fragment for injection into the cache
|
||||
// also handles side effects like updating comment counts and viewedAt timestamps
|
||||
function prepareComments (item, cache, newComment) {
|
||||
const existingComments = item.comments?.comments || []
|
||||
|
||||
// is the incoming new comment already in item's new comments or existing comments?
|
||||
if (existingNewComments.includes(newComment.id) || existingComments.some(comment => comment.id === newComment.id)) {
|
||||
return item
|
||||
}
|
||||
// is the incoming new comment already in item's existing comments?
|
||||
// if so, we don't need to update the cache
|
||||
if (existingComments.some(comment => comment.id === newComment.id)) return item
|
||||
|
||||
return { ...item, newComments: [...existingNewComments, newComment.id] }
|
||||
// count the new comment (+1) and its children (+ncomments)
|
||||
const totalNComments = newComment.ncomments + 1
|
||||
|
||||
const itemHierarchy = item.path.split('.')
|
||||
// update all ancestors comment count, but not the item itself
|
||||
const ancestors = itemHierarchy.slice(0, -1)
|
||||
updateAncestorsCommentCount(cache, ancestors, totalNComments)
|
||||
// update commentsViewedAt to now, and add the number of new comments
|
||||
const rootId = itemHierarchy[0]
|
||||
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
|
||||
|
||||
// add a flag to the new comment to indicate it was injected
|
||||
const injectedComment = { ...newComment, injected: true }
|
||||
|
||||
// an item can either have a comments.comments field, or not
|
||||
const payload = item.comments
|
||||
? {
|
||||
...item,
|
||||
ncomments: item.ncomments + totalNComments,
|
||||
comments: {
|
||||
...item.comments,
|
||||
comments: [injectedComment, ...item.comments.comments]
|
||||
}
|
||||
}
|
||||
// when the fragment doesn't have a comments field, we just update stats fields
|
||||
: {
|
||||
...item,
|
||||
ncomments: item.ncomments + totalNComments
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function cacheNewComments (client, rootId, newComments, sort) {
|
||||
function cacheNewComments (cache, rootId, newComments, sort) {
|
||||
for (const newComment of newComments) {
|
||||
const { parentId } = newComment
|
||||
const topLevel = Number(parentId) === Number(rootId)
|
||||
|
||||
// if the comment is a top level comment, update the item
|
||||
// if the comment is a top level comment, update the item, else update the parent comment
|
||||
if (topLevel) {
|
||||
// merge the new comment into the item's newComments field, checking for duplicates
|
||||
itemUpdateQuery(client, rootId, sort, (data) => mergeNewComment(data, newComment))
|
||||
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
|
||||
} else {
|
||||
// if the comment is a reply, update the parent comment
|
||||
// merge the new comment into the parent comment's newComments field, checking for duplicates
|
||||
commentUpdateFragment(client, parentId, (data) => mergeNewComment(data, newComment))
|
||||
// if the comment is too deep, we can skip it
|
||||
const depth = calculateDepth(newComment.path, rootId, parentId)
|
||||
if (depth > COMMENT_DEPTH_LIMIT) continue
|
||||
// inject the new comment into the parent comment's comments field
|
||||
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// useLiveComments fetches new comments under an item (rootId), that arrives after the latest comment createdAt
|
||||
// and inserts them into the newComment client field of their parent comment/post.
|
||||
// useLiveComments fetches new comments under an item (rootId),
|
||||
// that are newer than the latest comment createdAt (after), and injects them into the cache.
|
||||
export default function useLiveComments (rootId, after, sort) {
|
||||
const latestKey = `liveCommentsLatest:${rootId}`
|
||||
const client = useApolloClient()
|
||||
const { cache } = useApolloClient()
|
||||
const [latest, setLatest] = useState(after)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
@ -72,8 +109,9 @@ export default function useLiveComments (rootId, after, sort) {
|
||||
useEffect(() => {
|
||||
if (!data?.newComments?.comments?.length) return
|
||||
|
||||
// merge and cache new comments in their parent comment/post
|
||||
cacheNewComments(client, rootId, data.newComments.comments, sort)
|
||||
// directly inject new comments into the cache, preserving scroll position
|
||||
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
|
||||
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
|
||||
|
||||
// update latest timestamp to the latest comment created at
|
||||
// save it to session storage, to persist between client-side navigations
|
||||
@ -82,5 +120,5 @@ export default function useLiveComments (rootId, after, sort) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(latestKey, newLatest)
|
||||
}
|
||||
}, [data, client, rootId, sort, latest])
|
||||
}, [data, cache, rootId, sort, latest])
|
||||
}
|
||||
|
@ -47,7 +47,6 @@ export const COMMENT_FIELDS = gql`
|
||||
otsHash
|
||||
ncomments
|
||||
nDirectComments
|
||||
newComments @client
|
||||
injected @client
|
||||
imgproxyUrls
|
||||
rel
|
||||
@ -176,7 +175,6 @@ export const COMMENT_WITH_NEW_RECURSIVE = gql`
|
||||
...CommentsRecursive
|
||||
}
|
||||
}
|
||||
newComments @client
|
||||
}
|
||||
`
|
||||
|
||||
@ -190,7 +188,6 @@ export const COMMENT_WITH_NEW_LIMITED = gql`
|
||||
...CommentFields
|
||||
}
|
||||
}
|
||||
newComments @client
|
||||
}
|
||||
`
|
||||
|
||||
@ -199,7 +196,6 @@ export const COMMENT_WITH_NEW_MINIMAL = gql`
|
||||
|
||||
fragment CommentWithNewMinimal on Item {
|
||||
...CommentFields
|
||||
newComments @client
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -59,7 +59,6 @@ export const ITEM_FIELDS = gql`
|
||||
bio
|
||||
ncomments
|
||||
nDirectComments
|
||||
newComments @client
|
||||
commentSats
|
||||
commentCredits
|
||||
lastCommentAt
|
||||
|
@ -4,6 +4,7 @@ import { decodeCursor, LIMIT } from './cursor'
|
||||
import { COMMENTS_LIMIT, SSR } from './constants'
|
||||
import { RetryLink } from '@apollo/client/link/retry'
|
||||
import { isMutationOperation, isQueryOperation } from '@apollo/client/utilities'
|
||||
|
||||
function isFirstPage (cursor, existingThings, limit = LIMIT) {
|
||||
if (cursor) {
|
||||
const decursor = decodeCursor(cursor)
|
||||
@ -323,11 +324,6 @@ function getClient (uri) {
|
||||
}
|
||||
}
|
||||
},
|
||||
newComments: {
|
||||
read (newComments) {
|
||||
return newComments || []
|
||||
}
|
||||
},
|
||||
injected: {
|
||||
read (injected) {
|
||||
return injected || false
|
||||
|
@ -17,11 +17,10 @@ export function updateAncestorsCommentCount (cache, ancestors, increment) {
|
||||
})
|
||||
}
|
||||
|
||||
// live comments - cache manipulations
|
||||
// updates the item query in the cache
|
||||
// this is used by live comments to update a top level item's newComments field
|
||||
export function itemUpdateQuery (client, id, sort, fn) {
|
||||
client.cache.updateQuery({
|
||||
// this is used by live comments to update a top level item's comments field
|
||||
export function updateItemQuery (cache, id, sort, fn) {
|
||||
cache.updateQuery({
|
||||
query: ITEM_FULL,
|
||||
// updateQuery needs the correct variables to update the correct item
|
||||
// the Item query might have the router.query.sort in the variables, so we need to pass it in if it exists
|
||||
@ -33,8 +32,8 @@ export function itemUpdateQuery (client, id, sort, fn) {
|
||||
}
|
||||
|
||||
// updates a comment fragment in the cache, with fallbacks for comments lacking CommentsRecursive or Comments altogether
|
||||
export function commentUpdateFragment (client, id, fn) {
|
||||
let result = client.cache.updateFragment({
|
||||
export function updateCommentFragment (cache, id, fn) {
|
||||
let result = cache.updateFragment({
|
||||
id: `Item:${id}`,
|
||||
fragment: COMMENT_WITH_NEW_RECURSIVE,
|
||||
fragmentName: 'CommentWithNewRecursive'
|
||||
@ -46,7 +45,7 @@ export function commentUpdateFragment (client, id, fn) {
|
||||
// sometimes comments can start to reach their depth limit, and lack adherence to the CommentsRecursive fragment
|
||||
// for this reason, we update the fragment with a limited version that only includes the CommentFields fragment
|
||||
if (!result) {
|
||||
result = client.cache.updateFragment({
|
||||
result = cache.updateFragment({
|
||||
id: `Item:${id}`,
|
||||
fragment: COMMENT_WITH_NEW_LIMITED,
|
||||
fragmentName: 'CommentWithNewLimited'
|
||||
@ -58,7 +57,7 @@ export function commentUpdateFragment (client, id, fn) {
|
||||
|
||||
// at the deepest level, the comment can't have any children, here we update only the newComments field.
|
||||
if (!result) {
|
||||
result = client.cache.updateFragment({
|
||||
result = cache.updateFragment({
|
||||
id: `Item:${id}`,
|
||||
fragment: COMMENT_WITH_NEW_MINIMAL,
|
||||
fragmentName: 'CommentWithNewMinimal'
|
||||
@ -71,19 +70,16 @@ export function commentUpdateFragment (client, id, fn) {
|
||||
return result
|
||||
}
|
||||
|
||||
// reads a nested comments fragment from the cache
|
||||
// this is used to read a comment and its children comments
|
||||
// it has a fallback for comments nearing the depth limit, that lack the CommentsRecursive fragment
|
||||
export function readCommentsFragment (client, id) {
|
||||
return client.cache.readFragment({
|
||||
id: `Item:${id}`,
|
||||
fragment: COMMENT_WITH_NEW_RECURSIVE,
|
||||
fragmentName: 'CommentWithNewRecursive'
|
||||
}) || client.cache.readFragment({
|
||||
id: `Item:${id}`,
|
||||
fragment: COMMENT_WITH_NEW_LIMITED,
|
||||
fragmentName: 'CommentWithNewLimited'
|
||||
})
|
||||
export function calculateDepth (path, rootId, parentId) {
|
||||
// calculate depth by counting path segments from root to parent
|
||||
const pathSegments = path.split('.')
|
||||
const rootIndex = pathSegments.indexOf(rootId.toString())
|
||||
const parentIndex = pathSegments.indexOf(parentId.toString())
|
||||
|
||||
// depth is the distance from root to parent in the path
|
||||
const depth = parentIndex - rootIndex
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
// finds the most recent createdAt timestamp from an array of comments
|
||||
|
Loading…
x
Reference in New Issue
Block a user